diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fb447ce8..5d4bebd7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,7 @@ arche/ | `User` | Accounts (email, slug, role, Argon2 hash, TOTP fields) | | `Session` | Sessions with token hash, expiration, IP, and user agent | | `Instance` | Containerized workspace (status, containerId, encrypted password, configSha) | -| `Connector` | Per-user connector records with encrypted config. Supported connector types are defined in `apps/web/src/lib/connectors/types.ts`: Linear, Notion, Zendesk, Ahrefs, Umami, custom MCP, Meta Ads, and Google Workspace products (Gmail, Drive, Calendar, Chat, People) | +| `Connector` | Per-user connector records with encrypted config. Supported connector types are defined in `apps/web/src/lib/connectors/types.ts`: Linear, Notion, Zendesk, Ahrefs, Umami, GitHub repositories, custom MCP, Meta Ads, and Google Workspace products (Gmail, Drive, Calendar, Chat, People) | | `ProviderCredential` | Per-user model provider credentials for providers such as OpenAI, Anthropic, Fireworks, OpenRouter, and OpenCode | | `ExternalIntegration` | Admin-managed integrations stored once for the deployment, such as Slack | | `SlackThreadBinding` | Mapping between Slack channel threads and OpenCode sessions | @@ -103,6 +103,20 @@ Runtime behavior: - `kb-config` (bare repo): runtime `CommonWorkspaceConfig.json` + generated `AGENTS.md` - both repos start empty and are populated by kickstart on first setup +### GitHub repositories as KB sources + +The GitHub connector wires GitHub's hosted remote MCP server (`api.githubcopilot.com/mcp/`) +into a workspace using an encrypted personal access token. Its "pinned repositories" +are **advisory, not an enforcement boundary**: they are injected into the workspace +prompt (`AGENTS.md`) to guide agents toward the most relevant source, but they are +**not** sent to the MCP server. The agent can read any repository the PAT can reach, +and read-only access depends on GitHub honoring the `X-MCP-Readonly` header. The raw +PAT is also embedded in the workspace MCP config (the same precedent as other API-key +connectors), so it is readable by any code the agent runs there. + +**Accepted risk / mitigation:** scope the token, not the repo list. Use a fine-grained +PAT whose read access is limited to exactly the repositories that should be reachable. + ## Desktop Vault Model Desktop keeps the existing single-user, single-workspace runtime model per process. diff --git a/apps/web/e2e/connectors-github.spec.ts b/apps/web/e2e/connectors-github.spec.ts new file mode 100644 index 00000000..f061ac55 --- /dev/null +++ b/apps/web/e2e/connectors-github.spec.ts @@ -0,0 +1,75 @@ +import { expect, test, type Page } from '@playwright/test' + +import { adminSlug } from './support/test-data' + +const APP_ORIGIN = 'http://127.0.0.1:3000' + +function isObjectRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function getGithubConnectorIds(value: unknown): string[] { + if (!isObjectRecord(value) || !Array.isArray(value.connectors)) { + throw new Error('Invalid connectors list response') + } + + return value.connectors.flatMap((connector) => { + if (!isObjectRecord(connector) || connector.type !== 'github' || typeof connector.id !== 'string') { + return [] + } + + return [connector.id] + }) +} + +async function removeGithubConnectors(page: Page): Promise { + const response = await page.request.get(`/api/u/${adminSlug}/connectors`) + expect(response.ok()).toBeTruthy() + + for (const connectorId of getGithubConnectorIds(await response.json())) { + const deleteResponse = await page.request.delete(`/api/u/${adminSlug}/connectors/${connectorId}`, { + headers: { origin: APP_ORIGIN }, + }) + expect(deleteResponse.ok()).toBeTruthy() + } +} + +async function openGithubConnectorDialog(page: Page) { + await page.goto(`/u/${adminSlug}/connectors`) + await expect(page.getByRole('heading', { name: 'Connectors' })).toBeVisible() + + const addFirstButton = page.getByRole('button', { name: 'Add your first connector' }) + if (await addFirstButton.isVisible()) { + await addFirstButton.click() + } else { + await page.getByRole('button', { name: 'Add connector' }).click() + } + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: /GitHub/ }).click() + return dialog +} + +test.beforeEach(async ({ page }) => { + await removeGithubConnectors(page) +}) + +test.afterEach(async ({ page }) => { + await removeGithubConnectors(page) +}) + +test('links a GitHub repository with a PAT', async ({ page }) => { + const dialog = await openGithubConnectorDialog(page) + + await dialog.getByLabel('Personal access token').fill('github_pat_e2e_example') + await dialog.getByLabel('Pinned repositories').fill('acme/api') + await dialog.getByLabel('Pinned repositories').press('Enter') + await dialog.getByRole('button', { name: 'Save connector' }).click() + + await expect(dialog).not.toBeVisible() + await expect(page.getByText('GitHub', { exact: true })).toBeVisible() + + await page.reload() + await expect(page.getByText('GitHub', { exact: true })).toBeVisible() +}) diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/__tests__/route.test.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/__tests__/route.test.ts index 6be5d0f9..00efd8f4 100644 --- a/apps/web/src/app/api/u/[slug]/connectors/[id]/__tests__/route.test.ts +++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/__tests__/route.test.ts @@ -121,6 +121,7 @@ vi.mock('@/lib/connectors/validators', () => ({ validateConnectorType: (...args: unknown[]) => mocks.validateConnectorTypeMock(...args), validateConnectorConfig: (...args: unknown[]) => mocks.validateConnectorConfigMock(...args), validateConnectorName: (...args: unknown[]) => mocks.validateConnectorNameMock(...args), + normalizeConnectorConfigForPersistence: (_type: unknown, config: unknown) => config, })) // --------------------------------------------------------------------------- diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/route.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/route.ts index 92d20b9a..c5fe62de 100644 --- a/apps/web/src/app/api/u/[slug]/connectors/[id]/route.ts +++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/route.ts @@ -13,6 +13,7 @@ import { sanitizeConnectorConfigForResponse } from '@/lib/connectors/response-co import { preserveConnectorToolPermissions } from '@/lib/connectors/tool-permissions' import type { ConnectorType } from '@/lib/connectors/types' import { + normalizeConnectorConfigForPersistence, validateConnectorConfig, validateConnectorName, validateConnectorType, @@ -248,9 +249,10 @@ export const PATCH = withAuth< { status: 400 } ) } + const configToPersist = normalizeConnectorConfigForPersistence(connectorType, mergedConfig) try { - updateData.config = encryptConfig(mergedConfig) - responseConfig = mergedConfig + updateData.config = encryptConfig(configToPersist) + responseConfig = configToPersist } catch (err) { const message = err instanceof Error ? err.message : 'Failed to encrypt config' return NextResponse.json( diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts index e2cf8e80..64244188 100644 --- a/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts +++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts @@ -84,7 +84,9 @@ export const POST = withAuth ({ } return { valid: true } }, + normalizeConnectorConfigForPersistence: (_type: unknown, config: unknown) => config, })) // --------------------------------------------------------------------------- diff --git a/apps/web/src/app/api/u/[slug]/connectors/route.ts b/apps/web/src/app/api/u/[slug]/connectors/route.ts index ad1b352a..a9a72475 100644 --- a/apps/web/src/app/api/u/[slug]/connectors/route.ts +++ b/apps/web/src/app/api/u/[slug]/connectors/route.ts @@ -11,6 +11,7 @@ import { } from '@/lib/connectors/require-connector-capability' import { isSingleInstanceConnectorType } from '@/lib/connectors/types' import { + normalizeConnectorConfigForPersistence, validateConnectorType, validateConnectorConfig, validateConnectorName, @@ -237,9 +238,11 @@ export const POST = withAuth { expect(onOpenChange).toHaveBeenCalledWith(false) }) + it('saves a GitHub PAT and pinned repository through the connector API', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'conn-github' }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const onOpenChange = vi.fn() + const onSaved = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: /GitHub/i })) + expect(screen.getByRole('button', { name: 'Save connector' }).hasAttribute('disabled')).toBe(true) + + fireEvent.change(screen.getByLabelText('Personal access token'), { + target: { value: 'github_pat_example' }, + }) + fireEvent.change(screen.getByLabelText('Pinned repositories'), { + target: { value: 'acme/api' }, + }) + fireEvent.keyDown(screen.getByLabelText('Pinned repositories'), { key: 'Enter' }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Save connector' }).hasAttribute('disabled')).toBe(false) + }) + fireEvent.click(screen.getByRole('button', { name: 'Save connector' })) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/u/alice/connectors') + expect(init.method).toBe('POST') + expect(JSON.parse(String(init.body))).toEqual({ + type: 'github', + name: 'GitHub', + config: { + pat: 'github_pat_example', + pinnedRepos: ['acme/api'], + toolsets: ['repos', 'git'], + }, + }) + expect(onSaved).toHaveBeenCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + it('filters connectors by search query on the selection step', () => { const onOpenChange = vi.fn() const onSaved = vi.fn() diff --git a/apps/web/src/components/connectors/add-connector-config.ts b/apps/web/src/components/connectors/add-connector-config.ts index 9cb083f8..95d23417 100644 --- a/apps/web/src/components/connectors/add-connector-config.ts +++ b/apps/web/src/components/connectors/add-connector-config.ts @@ -8,6 +8,11 @@ import { isCustomConnectorConfigurationComplete, type CustomConnectorFormState, } from '@/components/connectors/add-connector/custom/config' +import { + buildGithubConnectorConfig, + isGithubConnectorConfigurationComplete, + type GithubConnectorFormState, +} from '@/components/connectors/add-connector/github/config' import { buildGoogleWorkspaceConnectorConfig, isGoogleWorkspaceConnectorConfigurationComplete, @@ -47,6 +52,7 @@ export { export type { AhrefsConnectorFormState } from '@/components/connectors/add-connector/ahrefs/config' export type { CustomConnectorFormState } from '@/components/connectors/add-connector/custom/config' +export type { GithubConnectorFormState } from '@/components/connectors/add-connector/github/config' export type { GoogleWorkspaceConnectorFormState } from '@/components/connectors/add-connector/google-workspace/config' export type { LinearConnectorFormState } from '@/components/connectors/add-connector/linear/config' export type { NotionConnectorFormState } from '@/components/connectors/add-connector/notion/config' @@ -61,6 +67,7 @@ export type ConnectorFormState = | AhrefsConnectorFormState | UmamiConnectorFormState | GoogleWorkspaceConnectorFormState + | GithubConnectorFormState | CustomConnectorFormState export function buildConnectorConfig( @@ -79,6 +86,8 @@ export function buildConnectorConfig( return buildAhrefsConnectorConfig(state) case 'umami': return buildUmamiConnectorConfig(state) + case 'github': + return buildGithubConnectorConfig(state) case 'google_gmail': case 'google_drive': case 'google_calendar': @@ -104,6 +113,8 @@ export function isConnectorConfigurationComplete( return isAhrefsConnectorConfigurationComplete(state) case 'umami': return isUmamiConnectorConfigurationComplete(state) + case 'github': + return isGithubConnectorConfigurationComplete(state) case 'google_gmail': case 'google_drive': case 'google_calendar': diff --git a/apps/web/src/components/connectors/add-connector-modal.tsx b/apps/web/src/components/connectors/add-connector-modal.tsx index 897899b9..4571019d 100644 --- a/apps/web/src/components/connectors/add-connector-modal.tsx +++ b/apps/web/src/components/connectors/add-connector-modal.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { AhrefsSection } from '@/components/connectors/add-connector/ahrefs/section' import { CustomSection } from '@/components/connectors/add-connector/custom/section' +import { GithubSection } from '@/components/connectors/add-connector/github/section' import { GoogleWorkspaceSection } from '@/components/connectors/add-connector/google-workspace/section' import { LinearSection } from '@/components/connectors/add-connector/linear/section' import { MetaAdsSection } from '@/components/connectors/add-connector/meta-ads/section' @@ -69,6 +70,7 @@ export function AddConnectorModal({ const ahrefsRef = useRef(null) const umamiRef = useRef(null) const metaAdsRef = useRef(null) + const githubRef = useRef(null) const googleGmailRef = useRef(null) const googleDriveRef = useRef(null) const googleCalendarRef = useRef(null) @@ -83,6 +85,7 @@ export function AddConnectorModal({ ahrefs: ahrefsRef, umami: umamiRef, 'meta-ads': metaAdsRef, + github: githubRef, google_gmail: googleGmailRef, google_drive: googleDriveRef, google_calendar: googleCalendarRef, @@ -211,6 +214,17 @@ export function AddConnectorModal({ /> ), }, + { + type: 'github', + node: ( + + ), + }, { type: 'google_gmail', node: ( diff --git a/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx b/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx index 01b6a584..a6d1f5ce 100644 --- a/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx +++ b/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AddConnectorSectionHandle } from '@/components/connectors/add-connector/section-types' import { CustomSection } from '@/components/connectors/add-connector/custom/section' import { GoogleWorkspaceSection } from '@/components/connectors/add-connector/google-workspace/section' +import { GithubSection } from '@/components/connectors/add-connector/github/section' import { LinearSection } from '@/components/connectors/add-connector/linear/section' import { MetaAdsSection } from '@/components/connectors/add-connector/meta-ads/section' import { NotionSection } from '@/components/connectors/add-connector/notion/section' @@ -187,6 +188,87 @@ describe('add connector sections', () => { expect(result.config).toEqual({ authType: 'oauth' }) }) + it('submits a GitHub PAT and pinned repositories', () => { + const ref = createRef() + render() + + fireEvent.change(screen.getByLabelText('Personal access token'), { + target: { value: 'github_pat_example' }, + }) + fireEvent.change(screen.getByLabelText('Pinned repositories'), { + target: { value: 'acme/api' }, + }) + fireEvent.keyDown(screen.getByLabelText('Pinned repositories'), { key: 'Enter' }) + + expect(getHandle(ref).isComplete()).toBe(true) + expect(expectSuccessfulSubmission(ref)).toMatchObject({ + name: 'GitHub', + config: { + pat: 'github_pat_example', + pinnedRepos: ['acme/api'], + toolsets: ['repos', 'git'], + }, + }) + }) + + it('commits a valid repository left in the input when submitting without pressing Add', () => { + const ref = createRef() + render() + + fireEvent.change(screen.getByLabelText('Personal access token'), { + target: { value: 'github_pat_example' }, + }) + fireEvent.change(screen.getByLabelText('Pinned repositories'), { + target: { value: 'acme/api' }, + }) + fireEvent.keyDown(screen.getByLabelText('Pinned repositories'), { key: 'Enter' }) + // Second repo typed but not committed with Enter/Add. + fireEvent.change(screen.getByLabelText('Pinned repositories'), { + target: { value: 'acme/web' }, + }) + + expect(expectSuccessfulSubmission(ref)).toMatchObject({ + config: { + pat: 'github_pat_example', + pinnedRepos: ['acme/api', 'acme/web'], + toolsets: ['repos', 'git'], + }, + }) + }) + + it('validates GitHub repositories and becomes incomplete when the last repository is removed', () => { + const ref = createRef() + render() + + fireEvent.change(screen.getByLabelText('Personal access token'), { + target: { value: 'github_pat_example' }, + }) + fireEvent.change(screen.getByLabelText('Pinned repositories'), { + target: { value: 'acme/api/path' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + + expect(screen.getByText('Enter a repository as owner/repository.')).toBeTruthy() + expect(getHandle(ref).isComplete()).toBe(false) + + fireEvent.change(screen.getByLabelText('Pinned repositories'), { + target: { value: 'acme/api' }, + }) + fireEvent.keyDown(screen.getByLabelText('Pinned repositories'), { key: 'Enter' }) + fireEvent.keyDown(screen.getByLabelText('Pinned repositories'), { key: 'Enter' }) + + expect(screen.getAllByRole('button', { name: 'Remove acme/api' })).toHaveLength(1) + expect(getHandle(ref).isComplete()).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Remove acme/api' })) + + expect(getHandle(ref).isComplete()).toBe(false) + expect(getHandle(ref).getSubmission()).toEqual({ + ok: false, + message: 'Add at least one pinned repository.', + }) + }) + it('submits Umami API-key and login configurations', () => { const ref = createRef() render() diff --git a/apps/web/src/components/connectors/add-connector/github/config.ts b/apps/web/src/components/connectors/add-connector/github/config.ts new file mode 100644 index 00000000..55e207d6 --- /dev/null +++ b/apps/web/src/components/connectors/add-connector/github/config.ts @@ -0,0 +1,40 @@ +import { parseGithubConnectorConfig } from '@/lib/connectors/github' + +import type { ConnectorConfigResult } from '@/components/connectors/add-connector/types' + +export type GithubConnectorFormState = { + selectedType: 'github' + pat: string + pinnedRepos: string[] +} + +export function buildGithubConnectorConfig( + state: GithubConnectorFormState +): ConnectorConfigResult { + if (!state.pat.trim()) { + return { ok: false, message: 'GitHub personal access token is required.' } + } + + if (state.pinnedRepos.length === 0) { + return { ok: false, message: 'Add at least one pinned repository.' } + } + + const parsed = parseGithubConnectorConfig({ + pat: state.pat, + pinnedRepos: state.pinnedRepos, + }) + if (!parsed.ok) { + return { + ok: false, + message: 'Enter a valid GitHub personal access token and pinned repositories.', + } + } + + return { ok: true, value: parsed.config } +} + +export function isGithubConnectorConfigurationComplete( + state: GithubConnectorFormState +): boolean { + return buildGithubConnectorConfig(state).ok +} diff --git a/apps/web/src/components/connectors/add-connector/github/section.tsx b/apps/web/src/components/connectors/add-connector/github/section.tsx new file mode 100644 index 00000000..80587cda --- /dev/null +++ b/apps/web/src/components/connectors/add-connector/github/section.tsx @@ -0,0 +1,156 @@ +'use client' + +import { forwardRef, useImperativeHandle, useState } from 'react' +import { X } from 'lucide-react' + +import { ManualApiKeyField } from '@/components/connectors/add-connector/manual-api-key-field' +import { buildDefaultName } from '@/components/connectors/add-connector/shared' +import { + type AddConnectorSectionHandle, + type AddConnectorSectionProps, + type AddConnectorSubmissionResult, + useNotifyStateChange, +} from '@/components/connectors/add-connector/section-types' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { isGithubPinnedRepo } from '@/lib/connectors/github' + +import { + buildGithubConnectorConfig, + isGithubConnectorConfigurationComplete, +} from './config' + +export const GithubSection = forwardRef< + AddConnectorSectionHandle, + AddConnectorSectionProps +>(function GithubSection({ onStateChange, isActive }, ref) { + const [pat, setPat] = useState('') + const [pinnedRepos, setPinnedRepos] = useState([]) + const [repoInput, setRepoInput] = useState('') + const [repoError, setRepoError] = useState(null) + + const state = { + selectedType: 'github' as const, + pat, + pinnedRepos, + } + + useNotifyStateChange(onStateChange, state) + + function includesRepo(repos: string[], repo: string): boolean { + return repos.some((item) => item.toLowerCase() === repo.toLowerCase()) + } + + function addPinnedRepo() { + const repo = repoInput.trim() + if (!isGithubPinnedRepo(repo)) { + setRepoError('Enter a repository as owner/repository.') + return + } + + setPinnedRepos((current) => + includesRepo(current, repo) ? current : [...current, repo] + ) + setRepoInput('') + setRepoError(null) + } + + useImperativeHandle(ref, () => ({ + isComplete: () => isGithubConnectorConfigurationComplete(state), + getSubmission: (): AddConnectorSubmissionResult => { + // Commit a valid repo left in the input but not yet added so it isn't + // silently dropped when the user clicks Save without pressing Enter/Add. + const pendingRepo = repoInput.trim() + const submissionState = + isGithubPinnedRepo(pendingRepo) && !includesRepo(pinnedRepos, pendingRepo) + ? { ...state, pinnedRepos: [...pinnedRepos, pendingRepo] } + : state + + const configResult = buildGithubConnectorConfig(submissionState) + if (!configResult.ok) { + return { ok: false, message: configResult.message } + } + + return { + ok: true, + name: buildDefaultName('github'), + config: configResult.value, + } + }, + })) + + if (!isActive) return null + + return ( +
+
+ +

+ {buildDefaultName('github')} +

+
+ + + +
+ +
+ { + setRepoInput(event.target.value) + setRepoError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + addPinnedRepo() + } + }} + placeholder="owner/repository" + /> + +
+

+ Press Enter or Add after each repository. These repositories guide agents to the most relevant source code. +

+ {repoError ?

{repoError}

: null} + {pinnedRepos.length > 0 ? ( +
    + {pinnedRepos.map((repo) => ( +
  • + {repo} + +
  • + ))} +
+ ) : null} +
+
+ ) +}) diff --git a/apps/web/src/components/connectors/add-connector/shared.ts b/apps/web/src/components/connectors/add-connector/shared.ts index 585f312e..769a1296 100644 --- a/apps/web/src/components/connectors/add-connector/shared.ts +++ b/apps/web/src/components/connectors/add-connector/shared.ts @@ -43,6 +43,11 @@ export const CONNECTOR_TYPE_OPTIONS: { label: 'Meta Ads', description: 'Meta Marketing API insights via Arche MCP.', }, + { + type: 'github', + label: 'GitHub', + description: 'Read-only repository history and source code via GitHub MCP.', + }, { type: 'google_gmail', label: 'Gmail', @@ -92,6 +97,8 @@ export function buildDefaultName(type: ConnectorType): string { return 'Umami' case 'meta-ads': return 'Meta Ads' + case 'github': + return 'GitHub' case 'google_gmail': return 'Gmail' case 'google_drive': diff --git a/apps/web/src/components/connectors/connector-type-icon.tsx b/apps/web/src/components/connectors/connector-type-icon.tsx index 590ba9aa..7c957cd9 100644 --- a/apps/web/src/components/connectors/connector-type-icon.tsx +++ b/apps/web/src/components/connectors/connector-type-icon.tsx @@ -1,4 +1,4 @@ -import { BarChart3, BookText, Boxes, Globe, Search, Ticket } from 'lucide-react' +import { BarChart3, BookText, Boxes, Github, Globe, Search, Ticket } from 'lucide-react' import type { ConnectorType } from '@/lib/connectors/types' @@ -19,6 +19,8 @@ export function ConnectorTypeIcon({ type, className }: ConnectorTypeIconProps) { return case 'umami': return + case 'github': + return case 'custom': return default: diff --git a/apps/web/src/components/connectors/connectors-panel.tsx b/apps/web/src/components/connectors/connectors-panel.tsx index 28a3d41b..776e84fa 100644 --- a/apps/web/src/components/connectors/connectors-panel.tsx +++ b/apps/web/src/components/connectors/connectors-panel.tsx @@ -6,6 +6,7 @@ import { AddConnectorModal } from '@/components/connectors/add-connector-modal' import { ConnectorList } from '@/components/connectors/connector-list' import { ConnectorToolPermissionsDialog } from '@/components/connectors/connector-tool-permissions-dialog' import { getConnectorErrorMessage } from '@/components/connectors/error-messages' +import { GithubConnectorSettingsDialog } from '@/components/connectors/github-connector-settings-dialog' import { MetaAdsConnectorSettingsDialog } from '@/components/connectors/meta-ads-connector-settings-dialog' import { ZendeskConnectorSettingsDialog } from '@/components/connectors/zendesk-connector-settings-dialog' import type { @@ -15,7 +16,7 @@ import type { } from '@/components/connectors/types' import { notifyWorkspaceConfigChanged } from '@/lib/runtime/config-status-events' -type SettingsDialogVariant = 'generic' | 'meta-ads' | 'zendesk' +type SettingsDialogVariant = 'generic' | 'github' | 'meta-ads' | 'zendesk' export type ConnectorsPanelHandle = { openAddModal: () => void @@ -92,6 +93,8 @@ function formatTestResult(result: ConnectorTestResult): ConnectorTestState { function getSettingsDialogVariant(connector: ConnectorListItem | null): SettingsDialogVariant | null { switch (connector?.type) { + case 'github': + return 'github' case 'zendesk': return 'zendesk' case 'meta-ads': @@ -388,6 +391,18 @@ export function ConnectorsPanel({ slug, oauthReturnTo, ref }: ConnectorsPanelPro }} /> + { + if (!open) { + setSettingsConnector(null) + } + }} + /> + void +} + +function includesRepo(repos: string[], repo: string): boolean { + return repos.some((item) => item.toLowerCase() === repo.toLowerCase()) +} + +export function GithubConnectorSettingsDialog({ + open, + slug, + connectorId, + connectorName, + onOpenChange, +}: GithubConnectorSettingsDialogProps) { + const [pinnedRepos, setPinnedRepos] = useState([]) + const [repoInput, setRepoInput] = useState('') + const [repoError, setRepoError] = useState(null) + const [hasLoaded, setHasLoaded] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + + const isLoading = open && Boolean(connectorId) && !hasLoaded && error === null + + function resetState() { + setPinnedRepos([]) + setRepoInput('') + setRepoError(null) + setHasLoaded(false) + setIsSaving(false) + setError(null) + } + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen) resetState() + onOpenChange(nextOpen) + } + + useEffect(() => { + if (!open || !connectorId) return + + let cancelled = false + + async function loadConfig() { + try { + const response = await fetch(`/api/u/${slug}/connectors/${connectorId}`, { + cache: 'no-store', + }) + const data = (await response.json().catch(() => null)) as + | { config?: { pinnedRepos?: string[] }; error?: string } + | null + + if (cancelled) return + + if (!response.ok || !data) { + setError(getConnectorErrorMessage(data, 'load_settings_failed')) + return + } + + setPinnedRepos( + Array.isArray(data.config?.pinnedRepos) ? data.config.pinnedRepos : [], + ) + setHasLoaded(true) + setError(null) + } catch { + if (!cancelled) { + setError(getConnectorErrorMessage(null, 'network_error')) + } + } + } + + void loadConfig() + + return () => { + cancelled = true + } + }, [connectorId, open, slug]) + + function addPinnedRepo() { + const repo = repoInput.trim() + if (!isGithubPinnedRepo(repo)) { + setRepoError('Enter a repository as owner/repository.') + return + } + + setPinnedRepos((current) => + includesRepo(current, repo) ? current : [...current, repo], + ) + setRepoInput('') + setRepoError(null) + } + + async function handleSave() { + if (!connectorId || !hasLoaded || isLoading || isSaving) return + + const pendingRepo = repoInput.trim() + const finalRepos = + isGithubPinnedRepo(pendingRepo) && !includesRepo(pinnedRepos, pendingRepo) + ? [...pinnedRepos, pendingRepo] + : pinnedRepos + + setIsSaving(true) + setError(null) + + try { + const response = await fetch(`/api/u/${slug}/connectors/${connectorId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ config: { pinnedRepos: finalRepos } }), + }) + const data = (await response.json().catch(() => null)) as + | { error?: string } + | null + + if (!response.ok) { + setError(getConnectorErrorMessage(data, 'save_failed')) + return + } + + handleOpenChange(false) + } catch { + setError(getConnectorErrorMessage(null, 'network_error')) + } finally { + setIsSaving(false) + } + } + + return ( + + + + GitHub settings + + Manage pinned repositories for {connectorName ?? 'this connector'}. + Pinned repositories guide agents toward the most relevant source code. + + + +
+ {isLoading ? ( +
+ + Loading settings... +
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {hasLoaded ? ( +
+
+ +
+ { + setRepoInput(event.target.value) + setRepoError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + addPinnedRepo() + } + }} + placeholder="owner/repository" + disabled={isSaving} + /> + +
+

+ Press Enter or Add after each repository. +

+ {repoError ? ( +

{repoError}

+ ) : null} + {pinnedRepos.length > 0 ? ( +
    + {pinnedRepos.map((repo) => ( +
  • + {repo} + +
  • + ))} +
+ ) : ( +

+ No pinned repositories. Add at least one to guide agents to relevant source code. +

+ )} +
+
+ ) : null} + + + +
+ + +
+
+
+
+ ) +} diff --git a/apps/web/src/lib/__tests__/agent-capabilities.test.ts b/apps/web/src/lib/__tests__/agent-capabilities.test.ts index 3071f6e0..4cf90652 100644 --- a/apps/web/src/lib/__tests__/agent-capabilities.test.ts +++ b/apps/web/src/lib/__tests__/agent-capabilities.test.ts @@ -649,6 +649,10 @@ describe('getConnectorCapabilityId', () => { expect(getConnectorCapabilityId('ahrefs', 'any-id')).toBe('globalahrefs') }) + it('returns global id for GitHub type', () => { + expect(getConnectorCapabilityId('github', 'any-id')).toBe('globalgithub') + }) + it('returns global id for umami type', () => { expect(getConnectorCapabilityId('umami', 'any-id')).toBe('globalumami') }) diff --git a/apps/web/src/lib/__tests__/agent-connector-capabilities.test.ts b/apps/web/src/lib/__tests__/agent-connector-capabilities.test.ts index 009aa92f..d0396e78 100644 --- a/apps/web/src/lib/__tests__/agent-connector-capabilities.test.ts +++ b/apps/web/src/lib/__tests__/agent-connector-capabilities.test.ts @@ -38,6 +38,15 @@ describe('agent connector capabilities', () => { ownerKind: null, ownerSlug: null, }, + { + id: 'globalgithub', + type: 'github', + name: 'GitHub', + enabled: false, + scope: 'type', + ownerKind: null, + ownerSlug: null, + }, { id: 'globalgooglecalendar', type: 'google_calendar', diff --git a/apps/web/src/lib/__tests__/workspace-tool-display.test.ts b/apps/web/src/lib/__tests__/workspace-tool-display.test.ts index 3edc372c..586bcb18 100644 --- a/apps/web/src/lib/__tests__/workspace-tool-display.test.ts +++ b/apps/web/src/lib/__tests__/workspace-tool-display.test.ts @@ -50,6 +50,11 @@ describe('workspace-tool-display', () => { groupLabel: 'Using Linear', commandLabel: 'get issue', }) + expect(getWorkspaceToolDisplay('arche_github_conn123_list_commits')).toEqual({ + isConnectorTool: true, + groupLabel: 'Using GitHub', + commandLabel: 'list commits', + }) }) it('leaves built-in tool names untouched', () => { diff --git a/apps/web/src/lib/agent-capabilities.ts b/apps/web/src/lib/agent-capabilities.ts index 399be77e..c9e1953e 100644 --- a/apps/web/src/lib/agent-capabilities.ts +++ b/apps/web/src/lib/agent-capabilities.ts @@ -76,6 +76,7 @@ const SINGLE_INSTANCE_AGENT_CONNECTOR_CAPABILITY_IDS = { notion: 'globalnotion', zendesk: 'globalzendesk', ahrefs: 'globalahrefs', + github: 'globalgithub', umami: 'globalumami', google_gmail: 'globalgooglegmail', google_drive: 'globalgoogledrive', diff --git a/apps/web/src/lib/agent-connector-capabilities.ts b/apps/web/src/lib/agent-connector-capabilities.ts index 3b6fa86e..c7db5249 100644 --- a/apps/web/src/lib/agent-connector-capabilities.ts +++ b/apps/web/src/lib/agent-connector-capabilities.ts @@ -26,6 +26,7 @@ const SINGLE_INSTANCE_CONNECTOR_LABELS = { notion: 'Notion', zendesk: 'Zendesk', ahrefs: 'Ahrefs', + github: 'GitHub', umami: 'Umami', google_gmail: 'Gmail', google_drive: 'Google Drive', diff --git a/apps/web/src/lib/connectors/__tests__/github-config.test.ts b/apps/web/src/lib/connectors/__tests__/github-config.test.ts new file mode 100644 index 00000000..1bdf69e5 --- /dev/null +++ b/apps/web/src/lib/connectors/__tests__/github-config.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' + +import { + getGithubConnectionTestEndpoint, + getGithubMcpHeaders, + getGithubMcpServerUrl, + parseGithubConnectorConfig, +} from '@/lib/connectors/github' + +describe('GitHub connector config', () => { + it('parses a valid GitHub.com config with restricted default toolsets', () => { + expect(parseGithubConnectorConfig({ + pat: ' github_pat_example ', + pinnedRepos: ['owner/repo', 'owner/repo'], + })).toEqual({ + ok: true, + config: { + pat: 'github_pat_example', + pinnedRepos: ['owner/repo'], + toolsets: ['repos', 'git'], + }, + }) + }) + + it('parses an allowed toolset subset', () => { + const parsed = parseGithubConnectorConfig({ + pat: 'ghp_example', + pinnedRepos: ['acme/platform'], + toolsets: ['repos'], + }) + + expect(parsed).toEqual({ + ok: true, + config: { + pat: 'ghp_example', + pinnedRepos: ['acme/platform'], + toolsets: ['repos'], + }, + }) + }) + + it('dedupes pinned repos case-insensitively, keeping the first spelling', () => { + expect(parseGithubConnectorConfig({ + pat: 'ghp_example', + pinnedRepos: ['Acme/API', 'acme/api', 'other/repo'], + })).toEqual({ + ok: true, + config: { + pat: 'ghp_example', + pinnedRepos: ['Acme/API', 'other/repo'], + toolsets: ['repos', 'git'], + }, + }) + }) + + it('drops stray keys and returns only the normalized config', () => { + expect(parseGithubConnectorConfig({ + pat: 'ghp_example', + pinnedRepos: ['owner/repo'], + host: 'https://github.example.com', + hasPat: true, + })).toEqual({ + ok: true, + config: { + pat: 'ghp_example', + pinnedRepos: ['owner/repo'], + toolsets: ['repos', 'git'], + }, + }) + }) + + it.each([ + { pat: 'token', pinnedRepos: ['owner/repo'] }, + { pat: 'ghp_example', pinnedRepos: 'owner/repo' }, + { pat: 'ghp_example', pinnedRepos: ['owner/repo/path'] }, + { pat: 'ghp_example', pinnedRepos: ['owner/ repo'] }, + { pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: ['all'] }, + { pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: [] }, + { pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: ['repos', 'repos'] }, + { pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: [' '] }, + ])('rejects invalid config %#', (config) => { + expect(parseGithubConnectorConfig(config)).toEqual({ ok: false }) + }) + + it('builds GitHub.com MCP and REST test URLs', () => { + const githubCom = parseGithubConnectorConfig({ + pat: 'ghp_example', + pinnedRepos: ['owner/repo'], + }) + + if (!githubCom.ok) { + throw new Error('expected valid GitHub connector configurations') + } + + expect(getGithubMcpServerUrl()).toBe('https://api.githubcopilot.com/mcp/') + expect(getGithubMcpHeaders(githubCom.config)).toEqual({ + Authorization: 'Bearer ghp_example', + 'X-MCP-Readonly': 'true', + 'X-MCP-Toolsets': 'repos,git', + }) + expect(getGithubConnectionTestEndpoint()).toBe('https://api.github.com/user') + }) +}) diff --git a/apps/web/src/lib/connectors/__tests__/oauth-config.test.ts b/apps/web/src/lib/connectors/__tests__/oauth-config.test.ts index adc1d5ff..621dd92d 100644 --- a/apps/web/src/lib/connectors/__tests__/oauth-config.test.ts +++ b/apps/web/src/lib/connectors/__tests__/oauth-config.test.ts @@ -207,6 +207,31 @@ describe('oauth-config', () => { }) expect(result.oauth).toEqual({ provider: 'linear', accessToken: 'new' }) }) + + it('preserves the stored GitHub pat when next config omits it', async () => { + mockIsOAuthConnectorType.mockReturnValue(false) + const { mergeConnectorConfigWithPreservedOAuth } = await import('@/lib/connectors/oauth-config') + const result = mergeConnectorConfigWithPreservedOAuth({ + connectorType: 'github', + currentConfig: { pat: 'ghp_stored', pinnedRepos: ['owner/repo'] }, + nextConfig: { pinnedRepos: ['owner/repo', 'owner/other'] }, + }) + expect(result).toEqual({ + pat: 'ghp_stored', + pinnedRepos: ['owner/repo', 'owner/other'], + }) + }) + + it('does not overwrite a GitHub pat provided in next config', async () => { + mockIsOAuthConnectorType.mockReturnValue(false) + const { mergeConnectorConfigWithPreservedOAuth } = await import('@/lib/connectors/oauth-config') + const result = mergeConnectorConfigWithPreservedOAuth({ + connectorType: 'github', + currentConfig: { pat: 'ghp_stored', pinnedRepos: ['owner/repo'] }, + nextConfig: { pat: 'ghp_new', pinnedRepos: ['owner/repo'] }, + }) + expect(result.pat).toBe('ghp_new') + }) }) describe('clearConnectorOAuthConfig', () => { diff --git a/apps/web/src/lib/connectors/__tests__/response-config.test.ts b/apps/web/src/lib/connectors/__tests__/response-config.test.ts index 8582b499..9751d5a1 100644 --- a/apps/web/src/lib/connectors/__tests__/response-config.test.ts +++ b/apps/web/src/lib/connectors/__tests__/response-config.test.ts @@ -30,6 +30,18 @@ describe('sanitizeConnectorConfigForResponse', () => { expect(sanitizeConnectorConfigForResponse('custom', config)).toBe(config) }) + it('redacts GitHub personal access tokens from responses', () => { + expect(sanitizeConnectorConfigForResponse('github', { + pat: 'github_pat_secret', + pinnedRepos: ['acme/api'], + toolsets: ['repos', 'git'], + })).toEqual({ + hasPat: true, + pinnedRepos: ['acme/api'], + toolsets: ['repos', 'git'], + }) + }) + it('redacts OAuth credentials while preserving connection metadata', () => { expect(sanitizeConnectorConfigForResponse('custom', { authType: 'oauth', diff --git a/apps/web/src/lib/connectors/__tests__/test-connection.test.ts b/apps/web/src/lib/connectors/__tests__/test-connection.test.ts index db6bfd7d..c4040175 100644 --- a/apps/web/src/lib/connectors/__tests__/test-connection.test.ts +++ b/apps/web/src/lib/connectors/__tests__/test-connection.test.ts @@ -256,6 +256,60 @@ describe('testConnectorConnection', () => { }) }) + describe('github', () => { + it('calls the GitHub user endpoint with the PAT', async () => { + fetchSpy = mockFetch({ status: 200 }) + + const result = await testConnectorConnection('github', { + pat: 'github_pat_example', + pinnedRepos: ['acme/api'], + }) + + expect(result).toEqual({ ok: true, tested: true, message: 'GitHub connection verified.' }) + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.github.com/user') + expect((init.headers as Record).Authorization).toBe('Bearer github_pat_example') + }) + + it('does not test invalid GitHub configuration', async () => { + fetchSpy = vi.spyOn(globalThis, 'fetch') + + const result = await testConnectorConnection('github', { + pat: 'invalid', + pinnedRepos: ['acme/api'], + }) + + expect(result).toEqual({ + ok: false, + tested: false, + message: 'Invalid GitHub connector configuration', + }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it.each([401, 403, 429])('returns a test failure for GitHub status %i', async (status) => { + fetchSpy = mockFetch({ status }) + + const result = await testConnectorConnection('github', { + pat: 'github_pat_example', + pinnedRepos: ['acme/api'], + }) + + expect(result).toEqual({ ok: false, tested: true, message: `GitHub test failed (${status})` }) + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.github.com/user', + expect.objectContaining({ + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + Authorization: 'Bearer github_pat_example', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }), + ) + }) + }) + describe('notion', () => { it('calls Notion REST API with API key in manual mode', async () => { fetchSpy = mockFetch({ status: 200 }) diff --git a/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts b/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts index f61ed740..bc1f5bb0 100644 --- a/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts +++ b/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts @@ -176,6 +176,43 @@ describe('loadConnectorToolInventory', () => { ) }) + it('loads GitHub tools with the read-only and toolset restrictions', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + tools: [{ name: 'list_commits', description: 'List commits' }], + }, + }), + }) + vi.stubGlobal('fetch', fetchMock) + mocks.getConnectorMcpServerUrl.mockReturnValue('https://api.githubcopilot.com/mcp/') + + const result = await loadConnectorToolInventory({ + type: 'github', + config: { + pat: 'github_pat_example', + pinnedRepos: ['acme/api'], + }, + }) + + expect(result).toEqual({ + ok: true, + tools: [{ name: 'list_commits', title: 'List commits', description: 'List commits' }], + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.githubcopilot.com/mcp/', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer github_pat_example', + 'X-MCP-Readonly': 'true', + 'X-MCP-Toolsets': 'repos,git', + }), + }), + ) + expect(mocks.validateConnectorTestEndpoint).not.toHaveBeenCalled() + }) + it('uses API key auth for any remote connector inventory', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, diff --git a/apps/web/src/lib/connectors/__tests__/types.test.ts b/apps/web/src/lib/connectors/__tests__/types.test.ts index e8a429a5..21b07af0 100644 --- a/apps/web/src/lib/connectors/__tests__/types.test.ts +++ b/apps/web/src/lib/connectors/__tests__/types.test.ts @@ -6,6 +6,7 @@ describe('connector types', () => { it('isConnectorType returns true for known types', () => { expect(isConnectorType('linear')).toBe(true) expect(isConnectorType('notion')).toBe(true) + expect(isConnectorType('github')).toBe(true) expect(isConnectorType('custom')).toBe(true) }) @@ -17,6 +18,7 @@ describe('connector types', () => { it('isSingleInstanceConnectorType returns true for single-instance types', () => { expect(isSingleInstanceConnectorType('linear')).toBe(true) expect(isSingleInstanceConnectorType('google_drive')).toBe(true) + expect(isSingleInstanceConnectorType('github')).toBe(true) }) it('isSingleInstanceConnectorType returns false for multi-instance types', () => { diff --git a/apps/web/src/lib/connectors/github.ts b/apps/web/src/lib/connectors/github.ts new file mode 100644 index 00000000..2f90719d --- /dev/null +++ b/apps/web/src/lib/connectors/github.ts @@ -0,0 +1,99 @@ +const GITHUB_PAT_PATTERN = /^(?:ghp_|github_pat_)\S+$/ +const GITHUB_PINNED_REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/ +const GITHUB_MCP_SERVER_URL = 'https://api.githubcopilot.com/mcp/' +const GITHUB_CONNECTION_TEST_ENDPOINT = 'https://api.github.com/user' + +export const DEFAULT_GITHUB_TOOLSETS = ['repos', 'git'] as const + +const GITHUB_TOOLSETS = new Set(DEFAULT_GITHUB_TOOLSETS) + +export type GithubConnectorConfig = { + pat: string + pinnedRepos: string[] + toolsets: string[] +} + +export type GithubConnectorConfigParseResult = + | { ok: true; config: GithubConnectorConfig } + | { ok: false } + +export function isGithubPat(value: string): boolean { + return GITHUB_PAT_PATTERN.test(value.trim()) +} + +export function isGithubPinnedRepo(value: string): boolean { + return GITHUB_PINNED_REPO_PATTERN.test(value.trim()) +} + +function parseToolsets(value: unknown): string[] | null { + if (value === undefined) return [...DEFAULT_GITHUB_TOOLSETS] + if (!Array.isArray(value) || value.length === 0) return null + + const toolsets = value.map((toolset) => + typeof toolset === 'string' ? toolset.trim() : '' + ) + if ( + toolsets.some((toolset) => !GITHUB_TOOLSETS.has(toolset)) || + new Set(toolsets).size !== toolsets.length + ) { + return null + } + + return toolsets +} + +export function parseGithubConnectorConfig( + config: Record +): GithubConnectorConfigParseResult { + const pat = typeof config.pat === 'string' ? config.pat.trim() : '' + if (!isGithubPat(pat)) return { ok: false } + + if (!Array.isArray(config.pinnedRepos)) return { ok: false } + const pinnedRepos = config.pinnedRepos.map((repo) => + typeof repo === 'string' ? repo.trim() : '' + ) + if (pinnedRepos.some((repo) => !isGithubPinnedRepo(repo))) return { ok: false } + + const toolsets = parseToolsets(config.toolsets) + if (!toolsets) return { ok: false } + + return { + ok: true, + config: { + pat, + pinnedRepos: dedupePinnedRepos(pinnedRepos), + toolsets, + }, + } +} + +// GitHub repository names are case-insensitive, so `Acme/API` and `acme/api` +// refer to the same repo. Dedupe case-insensitively while preserving the first +// spelling the user entered. +function dedupePinnedRepos(repos: string[]): string[] { + const seen = new Set() + const deduped: string[] = [] + for (const repo of repos) { + const key = repo.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + deduped.push(repo) + } + return deduped +} + +export function getGithubMcpServerUrl(): string { + return GITHUB_MCP_SERVER_URL +} + +export function getGithubMcpHeaders(config: GithubConnectorConfig): Record { + return { + Authorization: `Bearer ${config.pat}`, + 'X-MCP-Readonly': 'true', + 'X-MCP-Toolsets': config.toolsets.join(','), + } +} + +export function getGithubConnectionTestEndpoint(): string { + return GITHUB_CONNECTION_TEST_ENDPOINT +} diff --git a/apps/web/src/lib/connectors/mcp/__tests__/server-url.test.ts b/apps/web/src/lib/connectors/mcp/__tests__/server-url.test.ts index 8592c4f2..4ef25e3a 100644 --- a/apps/web/src/lib/connectors/mcp/__tests__/server-url.test.ts +++ b/apps/web/src/lib/connectors/mcp/__tests__/server-url.test.ts @@ -86,6 +86,13 @@ describe('getConnectorMcpServerUrl', () => { expect(getConnectorMcpServerUrl('umami', { baseUrl: 'https://api.umami.is/v1' })).toBeNull() }) + it('returns the hosted GitHub MCP URL for valid GitHub connector config', () => { + expect(getConnectorMcpServerUrl('github', { + pat: 'github_pat_example', + pinnedRepos: ['acme/api'], + })).toBe('https://api.githubcopilot.com/mcp/') + }) + it('does not let stored OAuth MCP URLs influence hosted connector URLs except Linear and Notion', () => { expect(getConnectorMcpServerUrl('google_gmail', { authType: 'oauth', diff --git a/apps/web/src/lib/connectors/mcp/server-url.ts b/apps/web/src/lib/connectors/mcp/server-url.ts index b82c26c2..74200c32 100644 --- a/apps/web/src/lib/connectors/mcp/server-url.ts +++ b/apps/web/src/lib/connectors/mcp/server-url.ts @@ -1,3 +1,4 @@ +import { getGithubMcpServerUrl, parseGithubConnectorConfig } from '@/lib/connectors/github' import { getGoogleWorkspaceMcpServerUrl, isGoogleWorkspaceConnectorType } from '@/lib/connectors/google-workspace' import { getConnectorOAuthConfig } from '@/lib/connectors/oauth-config' import type { ConnectorType } from '@/lib/connectors/types' @@ -9,6 +10,7 @@ const MCP_SERVER_URLS = { export function getConnectorMcpServerUrl(type: 'linear' | 'notion', config: Record): string export function getConnectorMcpServerUrl(type: 'custom', config: Record): string | null +export function getConnectorMcpServerUrl(type: 'github', config: Record): string | null export function getConnectorMcpServerUrl(type: 'google_gmail' | 'google_drive' | 'google_calendar' | 'google_chat' | 'google_people', config: Record): string export function getConnectorMcpServerUrl(type: ConnectorType, config: Record): string | null export function getConnectorMcpServerUrl(type: ConnectorType, config: Record): string | null { @@ -30,6 +32,10 @@ export function getConnectorMcpServerUrl(type: ConnectorType, config: Record): Reco } } +function sanitizeGithubConfigForResponse(config: Record): Record { + const sanitizedConfig = { ...config } + delete sanitizedConfig.pat + + return { + ...sanitizedConfig, + hasPat: typeof config.pat === 'string' && config.pat.length > 0, + } +} + const CONNECTOR_CONFIG_SANITIZERS: Partial> = { + github: sanitizeGithubConfigForResponse, 'meta-ads': sanitizeMetaAdsConfigForResponse, } diff --git a/apps/web/src/lib/connectors/test-connection.ts b/apps/web/src/lib/connectors/test-connection.ts index 0f17adac..34db5c6c 100644 --- a/apps/web/src/lib/connectors/test-connection.ts +++ b/apps/web/src/lib/connectors/test-connection.ts @@ -1,4 +1,5 @@ import { parseAhrefsConnectorConfig, testAhrefsConnection } from '@/lib/connectors/ahrefs' +import { getGithubConnectionTestEndpoint, parseGithubConnectorConfig } from '@/lib/connectors/github' import { getConnectorMcpServerUrl } from '@/lib/connectors/mcp/server-url' import { testMetaAdsConnection } from '@/lib/connectors/meta-ads' import { getConnectorAuthType, getConnectorOAuthConfig } from '@/lib/connectors/oauth-config' @@ -46,6 +47,7 @@ function getAccessToken(type: ConnectorType, config: Record): s case 'ahrefs': case 'umami': case 'custom': + case 'github': return null case 'google_gmail': case 'google_drive': @@ -205,6 +207,28 @@ const CONNECTOR_TEST_HANDLERS: Record = { } }, + github: async (config) => { + const parsed = parseGithubConnectorConfig(config) + if (!parsed.ok) { + return { ok: false, tested: false, message: 'Invalid GitHub connector configuration' } + } + + const response = await fetchWithTimeout(getGithubConnectionTestEndpoint(), { + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${parsed.config.pat}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + + if (!response.ok) { + return { ok: false, tested: true, message: `GitHub test failed (${response.status})` } + } + + return { ok: true, tested: true, message: 'GitHub connection verified.' } + }, + notion: async (config) => { const pending = getPendingOAuthMessage('notion', config) if (pending) return pending diff --git a/apps/web/src/lib/connectors/tool-inventory.ts b/apps/web/src/lib/connectors/tool-inventory.ts index 91df7861..067ca9d6 100644 --- a/apps/web/src/lib/connectors/tool-inventory.ts +++ b/apps/web/src/lib/connectors/tool-inventory.ts @@ -1,4 +1,5 @@ import { getAhrefsMcpTools, parseAhrefsConnectorConfig } from '@/lib/connectors/ahrefs' +import { getGithubMcpHeaders, parseGithubConnectorConfig } from '@/lib/connectors/github' import { getMetaAdsMcpTools, parseMetaAdsConnectorConfig } from '@/lib/connectors/meta-ads' import { getConnectorMcpServerUrl } from '@/lib/connectors/mcp/server-url' import { getConnectorAuthType, getConnectorOAuthConfig } from '@/lib/connectors/oauth-config' @@ -65,6 +66,13 @@ function buildRemoteHeaders( ...(toStringRecord(config.headers) ?? {}), } + if (type === 'github') { + const parsed = parseGithubConnectorConfig(config) + if (!parsed.ok) return null + + return { ...headers, ...getGithubMcpHeaders(parsed.config) } + } + if (getConnectorAuthType(config) === 'oauth') { const oauth = getConnectorOAuthConfig(type, config) if (!oauth?.accessToken) return null diff --git a/apps/web/src/lib/connectors/types.ts b/apps/web/src/lib/connectors/types.ts index 1ee19782..7dcb34dd 100644 --- a/apps/web/src/lib/connectors/types.ts +++ b/apps/web/src/lib/connectors/types.ts @@ -1,11 +1,11 @@ -export const CONNECTOR_TYPES = ['linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'custom', 'meta-ads', 'google_gmail', 'google_drive', 'google_calendar', 'google_chat', 'google_people'] as const +export const CONNECTOR_TYPES = ['linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'custom', 'meta-ads', 'github', 'google_gmail', 'google_drive', 'google_calendar', 'google_chat', 'google_people'] as const export type ConnectorType = (typeof CONNECTOR_TYPES)[number] export function isConnectorType(value: string): value is ConnectorType { return CONNECTOR_TYPES.includes(value as ConnectorType) } -export const SINGLE_INSTANCE_CONNECTOR_TYPES = ['linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'meta-ads', 'google_gmail', 'google_drive', 'google_calendar', 'google_chat', 'google_people'] as const satisfies readonly ConnectorType[] +export const SINGLE_INSTANCE_CONNECTOR_TYPES = ['linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'meta-ads', 'github', 'google_gmail', 'google_drive', 'google_calendar', 'google_chat', 'google_people'] as const satisfies readonly ConnectorType[] export function isSingleInstanceConnectorType(type: ConnectorType): boolean { return SINGLE_INSTANCE_CONNECTOR_TYPES.includes(type as (typeof SINGLE_INSTANCE_CONNECTOR_TYPES)[number]) diff --git a/apps/web/src/lib/connectors/validators.ts b/apps/web/src/lib/connectors/validators.ts index 7d4f0929..072caa52 100644 --- a/apps/web/src/lib/connectors/validators.ts +++ b/apps/web/src/lib/connectors/validators.ts @@ -1,4 +1,5 @@ import { getConnectorAuthType } from '@/lib/connectors/oauth-config' +import { parseGithubConnectorConfig } from '@/lib/connectors/github' import { getLinearOAuthActor, getLinearOAuthScopeValidationError, isLinearOAuthActor } from '@/lib/connectors/linear' import { isOAuthConnectorType } from '@/lib/connectors/oauth' import { validateMetaAdsConnectorConfig } from '@/lib/connectors/meta-ads-config' @@ -32,6 +33,7 @@ export const CONNECTOR_SCHEMAS: Record = { required: ['authType', 'appId', 'appSecret'], optional: ['permissions', 'selectedAdAccountIds', 'defaultAdAccountId', 'oauth'], }, + github: { required: ['pat', 'pinnedRepos'], optional: ['toolsets'] }, custom: { required: ['endpoint'], optional: [ @@ -56,6 +58,24 @@ export function validateConnectorType(type: string): type is ConnectorType { return CONNECTOR_TYPES.includes(type as ConnectorType) } +/** + * Returns the config that should be persisted for a connector, normalized to + * the canonical shape its parser produces. For GitHub this trims the PAT, + * dedupes pinned repos, and drops stray keys so the stored record matches what + * every consumer re-parses. Callers must validate the config first; if + * normalization unexpectedly fails the original config is returned unchanged. + */ +export function normalizeConnectorConfigForPersistence( + type: ConnectorType, + config: Record +): Record { + if (type === 'github') { + const parsed = parseGithubConnectorConfig(config) + if (parsed.ok) return { ...parsed.config } + } + return config +} + export function validateConnectorName(name: unknown): { valid: boolean; error?: string } { if (typeof name !== 'string') { return { valid: false, error: 'Name must be a string' } @@ -94,6 +114,12 @@ export function validateConnectorConfig( return validateMetaAdsConnectorConfig(config) } + if (type === 'github') { + return parseGithubConnectorConfig(config).ok + ? { valid: true } + : { valid: false, message: 'Invalid GitHub connector configuration' } + } + if (getConnectorAuthType(config) === 'oauth' && isOAuthConnectorType(type)) { if (type === 'linear' && config.oauthActor !== undefined && !isLinearOAuthActor(config.oauthActor)) { return { valid: false, message: 'Linear OAuth actor must be user or app' } diff --git a/apps/web/src/lib/services/__tests__/connector.test.ts b/apps/web/src/lib/services/__tests__/connector.test.ts index 5174969d..3b651340 100644 --- a/apps/web/src/lib/services/__tests__/connector.test.ts +++ b/apps/web/src/lib/services/__tests__/connector.test.ts @@ -18,6 +18,7 @@ import { findManyByUserId, findEnabledByUserId, findEnabledMcpByUserId, + findEnabledGithubConnectorsForUser, findHashEntriesByUserId, findCapabilityInventoryEntries, findManyByIds, @@ -72,6 +73,18 @@ describe('connectorService', () => { }) }) + describe('findEnabledGithubConnectorsForUser', () => { + it('selects enabled GitHub connector configs deterministically', async () => { + mockPrisma.connector.findMany.mockResolvedValue([]) + await findEnabledGithubConnectorsForUser('u1') + expect(mockPrisma.connector.findMany).toHaveBeenCalledWith({ + where: { userId: 'u1', type: 'github', enabled: true }, + select: { config: true }, + orderBy: { id: 'asc' }, + }) + }) + }) + describe('findHashEntriesByUserId', () => { it('orders by id asc', async () => { mockPrisma.connector.findMany.mockResolvedValue([]) diff --git a/apps/web/src/lib/services/connector.ts b/apps/web/src/lib/services/connector.ts index 4d6effeb..b495df76 100644 --- a/apps/web/src/lib/services/connector.ts +++ b/apps/web/src/lib/services/connector.ts @@ -36,6 +36,10 @@ export type ConnectorMcpEntry = { enabled: boolean } +export type ConnectorGithubEntry = { + config: string +} + export type ConnectorHashEntry = { id: string type: string @@ -91,6 +95,14 @@ export function findEnabledMcpByUserId(userId: string): Promise { + return prisma.connector.findMany({ + where: { userId, type: 'github', enabled: true }, + select: { config: true }, + orderBy: { id: 'asc' }, + }) +} + export function findHashEntriesByUserId(userId: string): Promise { return prisma.connector.findMany({ where: { userId }, diff --git a/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts b/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts index 66dc764c..29296d54 100644 --- a/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts +++ b/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import type { ConnectorRecord } from '../mcp-config' +import { shouldExposeConnectorViaGateway } from '../mcp-connector-config' // --------------------------------------------------------------------------- // Hoisted mocks @@ -362,6 +363,39 @@ describe('mcp-config', () => { }) }) + // ----------------------------------------------------------------------- + // GitHub connector + // ----------------------------------------------------------------------- + + describe('github', () => { + it('builds a direct read-only MCP config with restricted toolsets', () => { + passGates({ pat: 'github_pat_example', pinnedRepos: ['acme/api'] }) + const connector = makeConnector({ type: 'github', id: 'gh1' }) + + const result = buildMcpConfigFromConnectors([connector], { + gatewayTargets: { + gh1: { url: 'http://gateway/gh1/mcp', token: 'gateway-token' }, + }, + }) + + expect(result.mcpConfig.mcp['arche_github_gh1']).toEqual({ + type: 'remote', + url: 'https://api.githubcopilot.com/mcp/', + enabled: true, + headers: { + Authorization: 'Bearer github_pat_example', + 'X-MCP-Readonly': 'true', + 'X-MCP-Toolsets': 'repos,git', + }, + oauth: false, + }) + expect(shouldExposeConnectorViaGateway('github', { + pat: 'github_pat_example', + pinnedRepos: ['acme/api'], + })).toBe(false) + }) + }) + // ----------------------------------------------------------------------- // Custom connector // ----------------------------------------------------------------------- @@ -926,5 +960,31 @@ describe('mcp-config', () => { expect(result).not.toBeNull() expect(result!.mcpConfig.mcp['arche_notion_n1']!.type).toBe('local') }) + + it('injects GitHub directly without issuing a connector gateway token', async () => { + serviceMocks.userService.findIdBySlug.mockResolvedValue({ id: 'user-1' }) + serviceMocks.connectorService.findEnabledMcpByUserId.mockResolvedValue([ + makeConnector({ type: 'github', id: 'gh1', config: 'enc-github' }), + ]) + connectorMocks.decryptConfig.mockReturnValue({ + pat: 'github_pat_example', + pinnedRepos: ['acme/api'], + }) + + const result = await buildMcpConfigForSlug('my-slug') + + expect(connectorMocks.issueConnectorGatewayToken).not.toHaveBeenCalled() + expect(result?.mcpConfig.mcp['arche_github_gh1']).toEqual({ + type: 'remote', + url: 'https://api.githubcopilot.com/mcp/', + enabled: true, + headers: { + Authorization: 'Bearer github_pat_example', + 'X-MCP-Readonly': 'true', + 'X-MCP-Toolsets': 'repos,git', + }, + oauth: false, + }) + }) }) }) diff --git a/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts b/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts index c69b4e00..513c04ae 100644 --- a/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts +++ b/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts @@ -10,6 +10,8 @@ const findIdentityBySlugMock = vi.fn() const getEnabledProviderCredentialsForUserMock = vi.fn() const getEffectiveCredentialForUserMock = vi.fn() const decryptProviderSecretMock = vi.fn() +const decryptConfigMock = vi.fn() +const findEnabledGithubConnectorsForUserMock = vi.fn() let repoDir: string | null = null @@ -50,6 +52,12 @@ async function loadRuntimeArtifactsModule() { userService: { findIdentityBySlug: (...args: unknown[]) => findIdentityBySlugMock(...args), }, + connectorService: { + findEnabledGithubConnectorsForUser: (...args: unknown[]) => findEnabledGithubConnectorsForUserMock(...args), + }, + })) + vi.doMock('@/lib/connectors/crypto', () => ({ + decryptConfig: (...args: unknown[]) => decryptConfigMock(...args), })) vi.doMock('@/lib/providers/store', () => ({ getEnabledProviderCredentialsForUser: (...args: unknown[]) => getEnabledProviderCredentialsForUserMock(...args), @@ -72,6 +80,8 @@ describe('runtime artifacts', () => { getEnabledProviderCredentialsForUserMock.mockResolvedValue(new Map()) getEffectiveCredentialForUserMock.mockResolvedValue(null) decryptProviderSecretMock.mockReturnValue({ apiKey: 'unused' }) + decryptConfigMock.mockReturnValue({}) + findEnabledGithubConnectorsForUserMock.mockResolvedValue([]) findIdentityBySlugMock.mockResolvedValue({ id: 'user-1', slug: 'alice', @@ -102,6 +112,7 @@ describe('runtime artifacts', () => { } vi.unmock('@/lib/config-repo-store') + vi.unmock('@/lib/connectors/crypto') vi.unmock('@/lib/providers/crypto') vi.unmock('@/lib/providers/store') vi.unmock('@/lib/services') @@ -251,6 +262,63 @@ describe('runtime artifacts', () => { ) }) + it('appends enabled GitHub connector repositories to AGENTS instructions', async () => { + await createRuntimeRepo({ + 'AGENTS.md': '# Base instructions\n', + 'CommonWorkspaceConfig.json': createWorkspaceConfig(), + }) + findEnabledGithubConnectorsForUserMock.mockResolvedValue([ + { config: 'encrypted-github-config' }, + ]) + decryptConfigMock.mockReturnValue({ + pat: 'github_pat_example', + pinnedRepos: ['acme/web', 'acme/api'], + }) + + const { + buildWorkspaceRuntimeArtifacts, + getWebProviderGatewayConfig, + } = await loadRuntimeArtifactsModule() + + const artifacts = await buildWorkspaceRuntimeArtifacts('alice', getWebProviderGatewayConfig()) + + expect(findEnabledGithubConnectorsForUserMock).toHaveBeenCalledWith('user-1') + expect(artifacts.agentsMd).toContain('## Linked Repositories') + expect(artifacts.agentsMd).toContain('- `acme/api`') + expect(artifacts.agentsMd).toContain('- `acme/web`') + }) + + it('ignores undecryptable and invalid GitHub connector configs when injecting repositories', async () => { + await createRuntimeRepo({ + 'AGENTS.md': '# Base instructions\n', + 'CommonWorkspaceConfig.json': createWorkspaceConfig(), + }) + findEnabledGithubConnectorsForUserMock.mockResolvedValue([ + { config: 'valid-config' }, + { config: 'undecryptable-config' }, + { config: 'invalid-config' }, + ]) + decryptConfigMock.mockImplementation((config: string) => { + if (config === 'undecryptable-config') { + throw new Error('decrypt failed') + } + if (config === 'invalid-config') { + return { pat: 'invalid', pinnedRepos: ['acme/private'] } + } + return { pat: 'github_pat_example', pinnedRepos: ['acme/api'] } + }) + + const { + buildWorkspaceRuntimeArtifacts, + getWebProviderGatewayConfig, + } = await loadRuntimeArtifactsModule() + + const artifacts = await buildWorkspaceRuntimeArtifacts('alice', getWebProviderGatewayConfig()) + + expect(artifacts.agentsMd).toContain('- `acme/api`') + expect(artifacts.agentsMd).not.toContain('acme/private') + }) + it('adds configured Ollama models to the runtime provider config', async () => { await createRuntimeRepo({ 'CommonWorkspaceConfig.json': createWorkspaceConfig(), diff --git a/apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts b/apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts new file mode 100644 index 00000000..99f521fe --- /dev/null +++ b/apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { withLinkedRepositories } from '@/lib/spawner/runtime-config-utils' + +describe('withLinkedRepositories', () => { + it('returns the original instructions when no repositories are linked', () => { + expect(withLinkedRepositories('# Base instructions', [])).toBe('# Base instructions') + }) + + it('appends linked repository guidance and the pinned repositories', () => { + expect(withLinkedRepositories('# Base instructions', ['acme/api', 'acme/web'])).toBe( + '# Base instructions\n\n## Linked Repositories\n\n' + + 'You have GitHub MCP tools (`get_file_contents`, `search_code`, `list_commits`, `get_commit`) to query these repositories. Use `main` unless correlating across branches.\n\n' + + '- `acme/api`\n- `acme/web`\n' + ) + }) +}) diff --git a/apps/web/src/lib/spawner/mcp-connector-config.ts b/apps/web/src/lib/spawner/mcp-connector-config.ts index be520310..985e6ba3 100644 --- a/apps/web/src/lib/spawner/mcp-connector-config.ts +++ b/apps/web/src/lib/spawner/mcp-connector-config.ts @@ -1,4 +1,9 @@ import { parseAhrefsConnectorConfig } from '@/lib/connectors/ahrefs' +import { + getGithubMcpHeaders, + getGithubMcpServerUrl, + parseGithubConnectorConfig, +} from '@/lib/connectors/github' import { getGoogleWorkspaceMcpServerUrl, isGoogleWorkspaceConnectorType } from '@/lib/connectors/google-workspace' import { isMetaAdsConnectorReady, parseMetaAdsConnectorConfig } from '@/lib/connectors/meta-ads' import { getConnectorAuthType, getConnectorOAuthConfig } from '@/lib/connectors/oauth-config' @@ -121,6 +126,21 @@ function buildLinearMcpServerConfig(input: { } } +function buildGithubMcpServerConfig( + config: Record +): McpServerConfig | undefined { + const parsed = parseGithubConnectorConfig(config) + if (!parsed.ok) return undefined + + return { + type: 'remote', + url: getGithubMcpServerUrl(), + enabled: true, + headers: getGithubMcpHeaders(parsed.config), + oauth: false, + } +} + function buildCustomMcpServerConfig(input: { connectorId: string config: Record @@ -228,6 +248,8 @@ export function buildConnectorMcpServerConfig(input: { return buildNotionMcpServerConfig(input) case 'linear': return buildLinearMcpServerConfig(input) + case 'github': + return buildGithubMcpServerConfig(input.config) case 'custom': return buildCustomMcpServerConfig(input) case 'zendesk': @@ -246,6 +268,8 @@ export function buildConnectorMcpServerConfig(input: { } export function shouldExposeConnectorViaGateway(type: ConnectorType, config: Record): boolean { + if (type === 'github') return false + const parser = EMBEDDED_CONNECTOR_PARSERS[type] if (parser) return parser(config).ok diff --git a/apps/web/src/lib/spawner/runtime-artifacts.ts b/apps/web/src/lib/spawner/runtime-artifacts.ts index 041cb2e0..dd5bfbff 100644 --- a/apps/web/src/lib/spawner/runtime-artifacts.ts +++ b/apps/web/src/lib/spawner/runtime-artifacts.ts @@ -4,7 +4,9 @@ import * as path from 'node:path' import { readConfigRepoSnapshot } from '@/lib/config-repo-store' import { readCommonWorkspaceConfig, readConfigRepoFile } from '@/lib/common-workspace-config-store' +import { decryptConfig } from '@/lib/connectors/crypto' import { getConnectorGatewayBaseUrl } from '@/lib/connectors/gateway-config' +import { parseGithubConnectorConfig } from '@/lib/connectors/github' import { decryptProviderSecret } from '@/lib/providers/crypto' import { isOllamaSecret } from '@/lib/providers/ollama' import { getEffectiveCredentialForUser, getEnabledProviderCredentialsForUser } from '@/lib/providers/store' @@ -15,7 +17,7 @@ import { withSystemSkillBundles, } from '@/lib/skills/system-skills' import type { SkillBundle } from '@/lib/skills/types' -import { userService } from '@/lib/services' +import { connectorService, userService } from '@/lib/services' import { applyDefaultAgentModel, injectAlwaysOnAgentTools, @@ -27,6 +29,7 @@ import { import { buildMcpConfigForSlug } from '@/lib/spawner/mcp-config' import { withWorkspaceIdentity, + withLinkedRepositories, withWorkspacePermissionGuards, } from '@/lib/spawner/runtime-config-utils' import { @@ -114,6 +117,29 @@ async function getWorkspaceOwner(slug: string): Promise { return userService.findIdentityBySlug(slug).catch(() => null) } +async function getLinkedRepositoriesForOwner(owner: WorkspaceOwner): Promise { + if (!owner) return [] + + try { + const connectors = await connectorService.findEnabledGithubConnectorsForUser(owner.id) + const repos: string[] = [] + + for (const connector of connectors) { + try { + const parsed = parseGithubConnectorConfig(decryptConfig(connector.config)) + if (parsed.ok) repos.push(...parsed.config.pinnedRepos) + } catch { + continue + } + } + + return Array.from(new Set(repos)).sort((left, right) => left.localeCompare(right)) + } catch (error) { + console.warn('[workspace-runtime] Failed to load linked GitHub repositories', error) + return [] + } +} + async function readOptionalRepoTextFile(repoDir: string, filePath: string): Promise { return fs.readFile(path.join(repoDir, filePath), 'utf-8').catch((error: unknown) => { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { @@ -313,10 +339,11 @@ export async function buildWorkspaceAgentsMd( } const resolvedOwner = owner ?? (await getWorkspaceOwner(slug)) - return withWorkspaceIdentity(agentsResult.content, { + const agentsMd = withWorkspaceIdentity(agentsResult.content, { slug: resolvedOwner?.slug ?? slug, email: resolvedOwner?.email, }) + return withLinkedRepositories(agentsMd, await getLinkedRepositoriesForOwner(resolvedOwner)) } export async function buildWorkspaceRuntimeArtifacts( diff --git a/apps/web/src/lib/spawner/runtime-config-utils.ts b/apps/web/src/lib/spawner/runtime-config-utils.ts index 48137d75..1af7f134 100644 --- a/apps/web/src/lib/spawner/runtime-config-utils.ts +++ b/apps/web/src/lib/spawner/runtime-config-utils.ts @@ -77,3 +77,17 @@ export function withWorkspaceIdentity( return agentsMd + block } + +export function withLinkedRepositories(agentsMd: string, repos: string[]): string { + if (repos.length === 0) return agentsMd + + const list = repos.map((repo) => `- \`${repo}\``).join('\n') + const block = + `\n\n## Linked Repositories\n\n` + + `You have GitHub MCP tools (\`get_file_contents\`, \`search_code\`, ` + + `\`list_commits\`, \`get_commit\`) to query these ` + + `repositories. Use \`main\` unless correlating across branches.\n\n` + + `${list}\n` + + return agentsMd + block +} diff --git a/apps/web/src/lib/workspace-tool-display.ts b/apps/web/src/lib/workspace-tool-display.ts index 821a2ca0..eb7a6d6d 100644 --- a/apps/web/src/lib/workspace-tool-display.ts +++ b/apps/web/src/lib/workspace-tool-display.ts @@ -6,6 +6,7 @@ const CONNECTOR_TYPE_LABELS: Record = { zendesk: 'Zendesk', ahrefs: 'Ahrefs', umami: 'Umami', + github: 'GitHub', 'meta-ads': 'Meta Ads', custom: 'Custom Connector', google_gmail: 'Gmail', diff --git a/apps/web/tests/connectors.test.ts b/apps/web/tests/connectors.test.ts index 5a7f1071..fed75f8e 100644 --- a/apps/web/tests/connectors.test.ts +++ b/apps/web/tests/connectors.test.ts @@ -66,7 +66,7 @@ describe('connectors/crypto', () => { describe('connectors/types', () => { it('CONNECTOR_TYPES contains expected values', () => { expect(CONNECTOR_TYPES).toEqual([ - 'linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'custom', 'meta-ads', + 'linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'custom', 'meta-ads', 'github', 'google_gmail', 'google_drive', 'google_calendar', 'google_chat', 'google_people', ]) }) diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index eede6163..1fbb369e 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -18,6 +18,7 @@ export const sharedCoverageConfig = { exclude: sharedCoverageExclude, include: ['src/**/*.{ts,tsx}'], provider: 'v8' as const, + reportOnFailure: true, reporter: ['text', 'json-summary', 'html', 'lcov'], }