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
16 changes: 15 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
75 changes: 75 additions & 0 deletions apps/web/e2e/connectors-github.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<void> {
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()
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))

// ---------------------------------------------------------------------------
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/app/api/u/[slug]/connectors/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ export const POST = withAuth<TestConnectionResult | { error: string }, { slug: s
}
}

const result = await testConnectorConnection(connector.type, config, { customEndpointUrl })
const result = await testConnectorConnection(connector.type, config, {
customEndpointUrl,
})

if (result.ok && getConnectorAuthType(config) === 'oauth') {
const message = result.message ?? 'Connection verified.'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ vi.mock('@/lib/connectors/validators', () => ({
}
return { valid: true }
},
normalizeConnectorConfigForPersistence: (_type: unknown, config: unknown) => config,
}))

// ---------------------------------------------------------------------------
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/api/u/[slug]/connectors/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@/lib/connectors/require-connector-capability'
import { isSingleInstanceConnectorType } from '@/lib/connectors/types'
import {
normalizeConnectorConfigForPersistence,
validateConnectorType,
validateConnectorConfig,
validateConnectorName,
Expand Down Expand Up @@ -237,9 +238,11 @@ export const POST = withAuth<ConnectorResponse | { error: string; message?: stri
}
}

const configToPersist = normalizeConnectorConfigForPersistence(type, config)

let encryptedConfig: string
try {
encryptedConfig = encryptConfig(config)
encryptedConfig = encryptConfig(configToPersist)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to encrypt config'
return NextResponse.json(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,64 @@ describe('AddConnectorModal', () => {
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(
<AddConnectorModal
slug="alice"
existingConnectors={[]}
open
onOpenChange={onOpenChange}
onSaved={onSaved}
/>
)

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()
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/connectors/add-connector-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand All @@ -61,6 +67,7 @@ export type ConnectorFormState =
| AhrefsConnectorFormState
| UmamiConnectorFormState
| GoogleWorkspaceConnectorFormState
| GithubConnectorFormState
| CustomConnectorFormState

export function buildConnectorConfig(
Expand All @@ -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':
Expand All @@ -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':
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/connectors/add-connector-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -69,6 +70,7 @@ export function AddConnectorModal({
const ahrefsRef = useRef<AddConnectorSectionHandle>(null)
const umamiRef = useRef<AddConnectorSectionHandle>(null)
const metaAdsRef = useRef<AddConnectorSectionHandle>(null)
const githubRef = useRef<AddConnectorSectionHandle>(null)
const googleGmailRef = useRef<AddConnectorSectionHandle>(null)
const googleDriveRef = useRef<AddConnectorSectionHandle>(null)
const googleCalendarRef = useRef<AddConnectorSectionHandle>(null)
Expand All @@ -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,
Expand Down Expand Up @@ -211,6 +214,17 @@ export function AddConnectorModal({
/>
),
},
{
type: 'github',
node: (
<GithubSection
key={`github-${sessionKey}`}
ref={githubRef}
onStateChange={handleStateChange}
isActive={activeType === 'github'}
/>
),
},
{
type: 'google_gmail',
node: (
Expand Down
Loading
Loading