diff --git a/backend/src/api/powersync.test.ts b/backend/src/api/powersync.test.ts index 1a820c022..ac7e6696d 100644 --- a/backend/src/api/powersync.test.ts +++ b/backend/src/api/powersync.test.ts @@ -76,6 +76,9 @@ const powersyncSettings: Settings = { haystackApiKey: '', haystackWorkspace: '', haystackPipelines: '', + codingAgentWorkspaceWsUrl: '', + codingAgentBrokerUrl: '', + codingAgentServiceToken: '', } describe('PowerSync API', () => { diff --git a/backend/src/coding-agent/github-routes.test.ts b/backend/src/coding-agent/github-routes.test.ts new file mode 100644 index 000000000..634463516 --- /dev/null +++ b/backend/src/coding-agent/github-routes.test.ts @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for the coding-agent GitHub HTTP routes. They drive the Elysia app + * via `.handle()` with a fake `auth` (in-memory `getSession`) and injected broker + * seams — no bound port, no real broker, no DB. Asserts the discriminated DTOs + * and, crucially, that the broker is called with the *session* user.id (not any + * request-supplied id). + */ + +import type { Auth } from '@/auth/elysia-plugin' +import { createTestSettings } from '@/test-utils/settings' +import { describe, expect, it } from 'bun:test' +import type { AuthorizeUrlResult, BrokerGithubOptions, GithubStatusResult } from './github' +import { createCodingAgentGithubRoutes, type CodingAgentGithubDeps } from './github-routes' + +const sessionUser = (id: string) => + ({ api: { getSession: async () => ({ user: { id, isAnonymous: false } }) } }) as unknown as Auth +const noSession = { api: { getSession: async () => null } } as unknown as Auth + +const brokerSettings = createTestSettings({ + codingAgentBrokerUrl: 'https://broker.test', + codingAgentServiceToken: 'svc', +}) + +const get = (app: ReturnType, path: string) => + app.handle(new Request(`http://localhost${path}`, { headers: { Authorization: 'Bearer end-user-tok' } })) + +describe('GET /coding-agent/github/authorize-url', () => { + it('401s without a session', async () => { + const app = createCodingAgentGithubRoutes(brokerSettings, noSession) + const res = await get(app, '/coding-agent/github/authorize-url') + expect(res.status).toBe(401) + }) + + it('returns configured:false when the broker is not configured', async () => { + const app = createCodingAgentGithubRoutes(createTestSettings(), sessionUser('u1')) + const res = await get(app, '/coding-agent/github/authorize-url') + expect(await res.json()).toEqual({ configured: false }) + }) + + it('returns the url and calls the broker with the SESSION user.id', async () => { + let seenUserId = '' + const deps: CodingAgentGithubDeps = { + fetchAuthorizeUrlFn: async (_opts: BrokerGithubOptions, userId: string): Promise => { + seenUserId = userId + return { status: 'ok', url: 'https://github.com/login/oauth/authorize?state=x' } + }, + } + const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('alice'), deps) + const res = await get(app, '/coding-agent/github/authorize-url') + expect(await res.json()).toEqual({ + configured: true, + status: 'ok', + url: 'https://github.com/login/oauth/authorize?state=x', + }) + expect(seenUserId).toBe('alice') // identity comes from the session, not the request + }) + + it('maps a disabled broker to status:disabled', async () => { + const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('u1'), { + fetchAuthorizeUrlFn: async () => ({ status: 'disabled' }), + }) + const res = await get(app, '/coding-agent/github/authorize-url') + expect(await res.json()).toEqual({ configured: true, status: 'disabled' }) + }) + + it('maps a broker failure to status:failed (no reason leaked to the client)', async () => { + const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('u1'), { + fetchAuthorizeUrlFn: async () => ({ status: 'failed', reason: 'broker 502' }), + }) + const res = await get(app, '/coding-agent/github/authorize-url') + expect(await res.json()).toEqual({ configured: true, status: 'failed' }) + }) +}) + +describe('GET /coding-agent/github/status', () => { + it('401s without a session', async () => { + const app = createCodingAgentGithubRoutes(brokerSettings, noSession) + const res = await get(app, '/coding-agent/github/status') + expect(res.status).toBe(401) + }) + + it('returns configured:false when the broker is not configured', async () => { + const app = createCodingAgentGithubRoutes(createTestSettings(), sessionUser('u1')) + const res = await get(app, '/coding-agent/github/status') + expect(await res.json()).toEqual({ configured: false }) + }) + + it('reports connected:true and passes the session user.id', async () => { + let seenUserId = '' + const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('bob'), { + fetchGithubStatusFn: async (_opts: BrokerGithubOptions, userId: string): Promise => { + seenUserId = userId + return { status: 'ok', connected: true } + }, + }) + const res = await get(app, '/coding-agent/github/status') + expect(await res.json()).toEqual({ configured: true, status: 'ok', connected: true }) + expect(seenUserId).toBe('bob') + }) + + it('reports connected:false', async () => { + const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('u1'), { + fetchGithubStatusFn: async () => ({ status: 'ok', connected: false }), + }) + const res = await get(app, '/coding-agent/github/status') + expect(await res.json()).toEqual({ configured: true, status: 'ok', connected: false }) + }) + + it('maps a broker failure to status:failed', async () => { + const app = createCodingAgentGithubRoutes(brokerSettings, sessionUser('u1'), { + fetchGithubStatusFn: async () => ({ status: 'failed', reason: 'broker timeout' }), + }) + const res = await get(app, '/coding-agent/github/status') + expect(await res.json()).toEqual({ configured: true, status: 'failed' }) + }) +}) diff --git a/backend/src/coding-agent/github-routes.ts b/backend/src/coding-agent/github-routes.ts new file mode 100644 index 000000000..eda0dc563 --- /dev/null +++ b/backend/src/coding-agent/github-routes.ts @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * User-facing GitHub connect/status HTTP endpoints backing the built-in + * assistant's `github_connect` / `github_status` tools (the design's "MCP + * management plane"). + * + * - `GET /coding-agent/github/authorize-url` → the per-user GitHub OAuth + * authorize URL the developer clicks to connect their account. + * - `GET /coding-agent/github/status` → whether the developer has connected. + * + * Both require an authenticated session (`{ auth: true }`); the developer's + * Better-Auth `user.id` is resolved server-side from that session and forwarded + * to the broker as `x-tb-user-id`. It is NEVER a request/tool argument, so the + * model invoking the tool cannot act as another user. This reuses the same + * broker base URL + service token as the #967 provisioning path. + */ + +import type { Auth } from '@/auth/elysia-plugin' +import { createAuthMacro } from '@/auth/elysia-plugin' +import { createStandaloneLogger } from '@/config/logger' +import type { Settings } from '@/config/settings' +import { safeErrorHandler } from '@/middleware/error-handling' +import { Elysia } from 'elysia' +import { + fetchAuthorizeUrl, + fetchGithubStatus, + type AuthorizeUrlResult, + type BrokerGithubOptions, + type GithubStatusResult, +} from './github' + +export type GithubAuthorizeUrlDto = + | { configured: false } + | { configured: true; status: 'ok'; url: string } + | { configured: true; status: 'disabled' } + | { configured: true; status: 'failed' } + +export type GithubStatusDto = + | { configured: false } + | { configured: true; status: 'ok'; connected: boolean } + | { configured: true; status: 'disabled' } + | { configured: true; status: 'failed' } + +/** Injectable seams so the route handlers are unit-testable without the network. */ +export type CodingAgentGithubDeps = { + fetchFn?: typeof fetch + fetchAuthorizeUrlFn?: (opts: BrokerGithubOptions, userId: string) => Promise + fetchGithubStatusFn?: (opts: BrokerGithubOptions, userId: string) => Promise +} + +/** + * Mount the GitHub connect/status routes under the `/coding-agent` prefix. + * Returns 200 with a discriminated DTO in all reachable cases (broker + * unconfigured, disabled, failed, ok) so the assistant tool can phrase a useful + * message rather than surfacing a raw HTTP error. The service token and broker + * error bodies are never logged or returned. + */ +export const createCodingAgentGithubRoutes = (settings: Settings, auth: Auth, deps?: CodingAgentGithubDeps) => { + const log = createStandaloneLogger(settings) + const fetchFn = deps?.fetchFn ?? globalThis.fetch + const authorizeUrl = deps?.fetchAuthorizeUrlFn ?? fetchAuthorizeUrl + const githubStatus = deps?.fetchGithubStatusFn ?? fetchGithubStatus + + const brokerConfigured = (): boolean => settings.codingAgentBrokerUrl.trim().length > 0 + const brokerOpts = (): BrokerGithubOptions => ({ + brokerUrl: settings.codingAgentBrokerUrl, + serviceToken: settings.codingAgentServiceToken, + fetchFn, + }) + + return new Elysia({ name: 'coding-agent-github-routes', prefix: '/coding-agent/github' }) + .onError(safeErrorHandler) + .use(createAuthMacro(auth)) + .guard({ auth: true }, (g) => + g + .get('/authorize-url', async ({ user }): Promise => { + if (!brokerConfigured()) { + return { configured: false } + } + const result = await authorizeUrl(brokerOpts(), user.id) + if (result.status === 'failed') { + log.error({ userId: user.id, reason: result.reason }, 'coding-agent: authorize-url failed') + return { configured: true, status: 'failed' } + } + if (result.status === 'disabled') { + return { configured: true, status: 'disabled' } + } + return { configured: true, status: 'ok', url: result.url } + }) + .get('/status', async ({ user }): Promise => { + if (!brokerConfigured()) { + return { configured: false } + } + const result = await githubStatus(brokerOpts(), user.id) + if (result.status === 'failed') { + log.error({ userId: user.id, reason: result.reason }, 'coding-agent: github status failed') + return { configured: true, status: 'failed' } + } + if (result.status === 'disabled') { + return { configured: true, status: 'disabled' } + } + return { configured: true, status: 'ok', connected: result.connected } + }), + ) +} diff --git a/backend/src/coding-agent/github.test.ts b/backend/src/coding-agent/github.test.ts new file mode 100644 index 000000000..104fe2b80 --- /dev/null +++ b/backend/src/coding-agent/github.test.ts @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import { fetchAuthorizeUrl, fetchGithubStatus } from './github' + +type Captured = { url: string; method?: string; headers: Record } +type Step = number | 'throw' | 'timeout' | { status: number; body: string } + +/** Programmable fetch: each step is an HTTP status (200 body inferred), an object + * with an explicit body, or 'throw'/'timeout' to reject. */ +const makeFetch = (steps: Step[]) => { + const calls: Captured[] = [] + let i = 0 + const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => { + const headers = Object.fromEntries(new Headers(init?.headers).entries()) + calls.push({ url: String(url), method: init?.method, headers }) + const step = steps[Math.min(i, steps.length - 1)] + i += 1 + if (step === 'throw') { + throw new Error('network down') + } + if (step === 'timeout') { + const err = new Error('timed out') + err.name = 'TimeoutError' + throw err + } + if (typeof step === 'object') { + return new Response(step.body, { status: step.status }) + } + return new Response(step === 200 ? '{}' : '', { status: step }) + }) as unknown as typeof fetch + return { fetchFn, calls } +} + +const opts = (fetchFn: typeof fetch, extra: Record = {}) => ({ + brokerUrl: 'https://broker.example/', + serviceToken: 'svc-token', + fetchFn, + timeoutMs: 100, + maxAttempts: 2, + ...extra, +}) + +describe('fetchAuthorizeUrl', () => { + it('GETs /github/authorize-url with the service token + user id, returns the url', async () => { + const { fetchFn, calls } = makeFetch([ + { status: 200, body: JSON.stringify({ url: 'https://github.com/login/oauth/authorize?x=1' }) }, + ]) + const result = await fetchAuthorizeUrl(opts(fetchFn), 'user-alice') + + expect(result).toEqual({ status: 'ok', url: 'https://github.com/login/oauth/authorize?x=1' }) + expect(calls).toHaveLength(1) + expect(calls[0].method).toBe('GET') + expect(calls[0].url).toBe('https://broker.example/github/authorize-url') // trailing slash normalized + expect(calls[0].headers.authorization).toBe('Bearer svc-token') + expect(calls[0].headers['x-tb-user-id']).toBe('user-alice') + }) + + it('maps 501 to disabled without retrying', async () => { + const { fetchFn, calls } = makeFetch([501, 200]) + expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'disabled' }) + expect(calls).toHaveLength(1) + }) + + it('maps a non-retryable 4xx to failed without retrying', async () => { + const { fetchFn, calls } = makeFetch([403, 200]) + expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker 403' }) + expect(calls).toHaveLength(1) + }) + + it('retries a 5xx then succeeds', async () => { + const { fetchFn, calls } = makeFetch([503, { status: 200, body: JSON.stringify({ url: 'https://x' }) }]) + expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'ok', url: 'https://x' }) + expect(calls).toHaveLength(2) + }) + + it('treats a missing/empty url in the body as failed (bad body)', async () => { + const { fetchFn } = makeFetch([{ status: 200, body: JSON.stringify({ url: '' }) }]) + expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker bad body' }) + }) + + it('treats invalid JSON as failed (bad body)', async () => { + const { fetchFn } = makeFetch([{ status: 200, body: 'not-json' }]) + expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker bad body' }) + }) + + it('maps a timeout to failed', async () => { + const { fetchFn } = makeFetch(['timeout']) + expect(await fetchAuthorizeUrl(opts(fetchFn, { maxAttempts: 1 }), 'u1')).toEqual({ + status: 'failed', + reason: 'broker timeout', + }) + }) + + it('retries a network error then fails as unreachable', async () => { + const { fetchFn, calls } = makeFetch(['throw', 'throw']) + expect(await fetchAuthorizeUrl(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker unreachable' }) + expect(calls).toHaveLength(2) + }) + + it('never carries an end-user token (only the service token)', async () => { + const { fetchFn, calls } = makeFetch([{ status: 200, body: JSON.stringify({ url: 'https://x' }) }]) + await fetchAuthorizeUrl(opts(fetchFn), 'u1') + expect(calls[0].headers.authorization).toBe('Bearer svc-token') + }) +}) + +describe('fetchGithubStatus', () => { + it('GETs /github/status and returns connected:true', async () => { + const { fetchFn, calls } = makeFetch([{ status: 200, body: JSON.stringify({ connected: true }) }]) + expect(await fetchGithubStatus(opts(fetchFn), 'u1')).toEqual({ status: 'ok', connected: true }) + expect(calls[0].url).toBe('https://broker.example/github/status') + expect(calls[0].method).toBe('GET') + }) + + it('returns connected:false', async () => { + const { fetchFn } = makeFetch([{ status: 200, body: JSON.stringify({ connected: false }) }]) + expect(await fetchGithubStatus(opts(fetchFn), 'u1')).toEqual({ status: 'ok', connected: false }) + }) + + it('maps 501 to disabled', async () => { + const { fetchFn } = makeFetch([501]) + expect(await fetchGithubStatus(opts(fetchFn), 'u1')).toEqual({ status: 'disabled' }) + }) + + it('treats a non-boolean connected as failed (bad body)', async () => { + const { fetchFn } = makeFetch([{ status: 200, body: JSON.stringify({ connected: 'yes' }) }]) + expect(await fetchGithubStatus(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker bad body' }) + }) + + it('maps a 5xx after retries to failed', async () => { + const { fetchFn, calls } = makeFetch([500, 500]) + expect(await fetchGithubStatus(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker 500' }) + expect(calls).toHaveLength(2) + }) +}) diff --git a/backend/src/coding-agent/github.ts b/backend/src/coding-agent/github.ts new file mode 100644 index 000000000..cc79e2790 --- /dev/null +++ b/backend/src/coding-agent/github.ts @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Read-only GitHub-connection queries against the coding-agent broker, used by + * the user-facing `github_connect` / `github_status` assistant tools. + * + * As with {@link ./provision}, we authenticate to the broker with the shared + * service token and identify the developer with the Better-Auth `user.id` — the + * broker never sees the end-user's Better-Auth token. The `user.id` is resolved + * server-side from the authenticated session (see `./github-routes`), so the + * model that invokes these tools cannot spoof which user it acts as. + * + * Calls are bounded by a timeout and retried on transient (network / 5xx) + * failures; a terminal 4xx is never retried. + */ + +export type AuthorizeUrlResult = + | { status: 'ok'; url: string } + /** The broker is reachable but GitHub connect is disabled (501). */ + | { status: 'disabled' } + /** The broker could not produce a URL (timeout / network / 5xx / bad body). */ + | { status: 'failed'; reason: string } + +export type GithubStatusResult = + | { status: 'ok'; connected: boolean } + | { status: 'disabled' } + | { status: 'failed'; reason: string } + +export type BrokerGithubOptions = { + /** Broker base URL, e.g. https://coding-agent-broker.thunderbird.net */ + brokerUrl: string + /** Shared service token authenticating Thunderbolt → broker. */ + serviceToken: string + fetchFn: typeof fetch + /** Per-attempt timeout (ms). Default 8000. */ + timeoutMs?: number + /** Total attempts including the first. Default 2. */ + maxAttempts?: number +} + +const isRetryableStatus = (status: number): boolean => status >= 500 + +const normalizeBase = (brokerUrl: string): string => brokerUrl.trim().replace(/\/+$/, '') + +type RawResult = { kind: 'ok'; res: Response } | { kind: 'disabled' } | { kind: 'failed'; reason: string } + +/** + * GET `path` on the broker with the service token + `x-tb-user-id` header. + * Handles retry/timeout uniformly; the caller parses the body for the OK case. + * The reason string stays body-free (numeric status only) so a broker error body + * can never reach logs. + */ +const brokerGet = async (opts: BrokerGithubOptions, path: string, userId: string): Promise => { + const url = `${normalizeBase(opts.brokerUrl)}${path}` + const timeoutMs = opts.timeoutMs ?? 8000 + const maxAttempts = opts.maxAttempts ?? 2 + let lastReason = 'broker unreachable' + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let res: Awaited> + try { + res = await opts.fetchFn(url, { + method: 'GET', + headers: { + authorization: `Bearer ${opts.serviceToken}`, + 'x-tb-user-id': userId, + }, + signal: AbortSignal.timeout(timeoutMs), + }) + } catch (err) { + lastReason = err instanceof Error && err.name === 'TimeoutError' ? 'broker timeout' : 'broker unreachable' + continue + } + + if (res.ok) { + return { kind: 'ok', res } + } + if (res.status === 501) { + return { kind: 'disabled' } + } + lastReason = `broker ${res.status}` + if (!isRetryableStatus(res.status)) { + return { kind: 'failed', reason: lastReason } + } + // retryable 5xx — fall through to the next attempt + } + + return { kind: 'failed', reason: lastReason } +} + +/** GET /github/authorize-url → the per-user GitHub OAuth authorize URL to click. */ +export const fetchAuthorizeUrl = async (opts: BrokerGithubOptions, userId: string): Promise => { + const raw = await brokerGet(opts, '/github/authorize-url', userId) + if (raw.kind === 'disabled') { + return { status: 'disabled' } + } + if (raw.kind === 'failed') { + return { status: 'failed', reason: raw.reason } + } + let body: unknown + try { + body = await raw.res.json() + } catch { + return { status: 'failed', reason: 'broker bad body' } + } + const url = (body as { url?: unknown }).url + if (typeof url !== 'string' || url.length === 0) { + return { status: 'failed', reason: 'broker bad body' } + } + return { status: 'ok', url } +} + +/** GET /github/status → whether this developer has connected GitHub. */ +export const fetchGithubStatus = async (opts: BrokerGithubOptions, userId: string): Promise => { + const raw = await brokerGet(opts, '/github/status', userId) + if (raw.kind === 'disabled') { + return { status: 'disabled' } + } + if (raw.kind === 'failed') { + return { status: 'failed', reason: raw.reason } + } + let body: unknown + try { + body = await raw.res.json() + } catch { + return { status: 'failed', reason: 'broker bad body' } + } + const connected = (body as { connected?: unknown }).connected + if (typeof connected !== 'boolean') { + return { status: 'failed', reason: 'broker bad body' } + } + return { status: 'ok', connected } +} diff --git a/backend/src/coding-agent/index.ts b/backend/src/coding-agent/index.ts new file mode 100644 index 000000000..849c0d15c --- /dev/null +++ b/backend/src/coding-agent/index.ts @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export { createCodingAgentRoutes, type CodingAgentDeps } from './routes' +export { createCodingAgentProvider, codingAgentProviderId } from './provider' +export { createCodingAgentGithubRoutes, type CodingAgentGithubDeps } from './github-routes' diff --git a/backend/src/coding-agent/provider.test.ts b/backend/src/coding-agent/provider.test.ts new file mode 100644 index 000000000..fce334094 --- /dev/null +++ b/backend/src/coding-agent/provider.test.ts @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import type { Settings } from '@/config/settings' +import { createCodingAgentProvider } from './provider' + +const req = () => new Request('https://thunderbolt.example/v1/agents', { headers: { 'x-forwarded-proto': 'https' } }) + +// The provider only reads codingAgentWorkspaceWsUrl; a partial cast keeps the +// test free of full env/settings construction. +const settingsWith = (codingAgentWorkspaceWsUrl: string) => ({ codingAgentWorkspaceWsUrl }) as unknown as Settings + +describe('createCodingAgentProvider', () => { + it('advertises a single managed-acp websocket agent when the workspace endpoint is configured', () => { + const provider = createCodingAgentProvider() + const agents = provider.list(req(), settingsWith('wss://coding-agent.thunderbird.net/')) + + expect(agents).toHaveLength(1) + expect(agents[0]).toMatchObject({ + id: 'coding-agent', + name: 'Coding Agent', + type: 'managed-acp', + transport: 'websocket', + isSystem: 1, + }) + // URL points back at this backend's proxy route (wss derived from x-forwarded-proto). + expect(agents[0].url).toBe('wss://thunderbolt.example/v1/coding-agent/ws') + }) + + it('advertises nothing when the workspace endpoint is unset', () => { + const provider = createCodingAgentProvider() + expect(provider.list(req(), settingsWith(''))).toEqual([]) + expect(provider.list(req(), settingsWith(' '))).toEqual([]) + }) + + it('has a stable provider id', () => { + expect(createCodingAgentProvider().id).toBe('coding-agent') + }) +}) diff --git a/backend/src/coding-agent/provider.ts b/backend/src/coding-agent/provider.ts new file mode 100644 index 000000000..f80594285 --- /dev/null +++ b/backend/src/coding-agent/provider.ts @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { AgentProvider } from '@/agents' +import { buildWebSocketUrl } from '@/agents' +import type { Settings } from '@/config/settings' +import type { RemoteAgentDescriptor } from '@shared/acp-types' + +/** + * Provider id registered into the agent discovery registry. Stable: the registry + * dedupes on it, so re-importing this module never double-registers. + */ +export const codingAgentProviderId = 'coding-agent' + +/** + * Build the coding-agent provider. Surfaces a single `managed-acp` agent whose + * WS URL points at `/v1/coding-agent/ws` (this backend), which authenticates the + * developer, provisions their GitHub token, and proxies to the workspace shim. + * + * The agent is only advertised when the workspace endpoint is configured + * (`CODING_AGENT_WORKSPACE_WS_URL`); otherwise the list is empty so a deployment + * without the coding agent doesn't surface a dead entry. (Per-workspace, per-user + * discovery is a later increment — today there is one shared workspace endpoint.) + */ +export const createCodingAgentProvider = (): AgentProvider => ({ + id: codingAgentProviderId, + list: (request: Request, settings: Settings): RemoteAgentDescriptor[] => { + if (settings.codingAgentWorkspaceWsUrl.trim().length === 0) { + return [] + } + return [ + { + id: codingAgentProviderId, + name: 'Coding Agent', + type: 'managed-acp', + transport: 'websocket', + url: buildWebSocketUrl(request, '/coding-agent/ws'), + description: 'Self-hosted Cline agent that codes in a sandboxed workspace and acts as you on GitHub.', + icon: null, + isSystem: 1, + }, + ] + }, +}) diff --git a/backend/src/coding-agent/provision.test.ts b/backend/src/coding-agent/provision.test.ts new file mode 100644 index 000000000..710addf83 --- /dev/null +++ b/backend/src/coding-agent/provision.test.ts @@ -0,0 +1,112 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import { provisionWorkspaceToken } from './provision' + +type Captured = { url: string; method?: string; headers: Record } + +/** Programmable fetch: each step is an HTTP status, or 'throw'/'timeout' to reject. */ +const makeFetch = (steps: Array) => { + const calls: Captured[] = [] + let i = 0 + const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => { + const headers = Object.fromEntries(new Headers(init?.headers).entries()) + calls.push({ url: String(url), method: init?.method, headers }) + const step = steps[Math.min(i, steps.length - 1)] + i += 1 + if (step === 'throw') { + throw new Error('network down') + } + if (step === 'timeout') { + const err = new Error('timed out') + err.name = 'TimeoutError' + throw err + } + return new Response(step === 200 ? 'ok' : '', { status: step }) + }) as unknown as typeof fetch + return { fetchFn, calls } +} + +const opts = (fetchFn: typeof fetch, extra: Record = {}) => ({ + brokerUrl: 'https://broker.example/', + serviceToken: 'svc-token', + fetchFn, + timeoutMs: 100, + maxAttempts: 2, + ...extra, +}) + +describe('provisionWorkspaceToken', () => { + it('POSTs /github/provision with the service token + user id header (200 → ok, single call)', async () => { + const { fetchFn, calls } = makeFetch([200]) + const result = await provisionWorkspaceToken(opts(fetchFn), 'user-alice') + + expect(result).toEqual({ status: 'ok' }) + expect(calls).toHaveLength(1) + expect(calls[0].method).toBe('POST') + expect(calls[0].url).toBe('https://broker.example/github/provision') // trailing slash normalized + expect(calls[0].headers.authorization).toBe('Bearer svc-token') + expect(calls[0].headers['x-tb-user-id']).toBe('user-alice') + }) + + it('maps 409 to not_connected without retrying', async () => { + const { fetchFn, calls } = makeFetch([409, 200]) + expect(await provisionWorkspaceToken(opts(fetchFn), 'u1')).toEqual({ status: 'not_connected' }) + expect(calls).toHaveLength(1) + }) + + it('maps 501 to disabled without retrying', async () => { + const { fetchFn, calls } = makeFetch([501, 200]) + expect(await provisionWorkspaceToken(opts(fetchFn), 'u1')).toEqual({ status: 'disabled' }) + expect(calls).toHaveLength(1) + }) + + it('maps a non-retryable 4xx (401) to failed without retrying', async () => { + const { fetchFn, calls } = makeFetch([401, 200]) + expect(await provisionWorkspaceToken(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker 401' }) + expect(calls).toHaveLength(1) + }) + + it('retries a 5xx and fails after attempts are exhausted', async () => { + const { fetchFn, calls } = makeFetch([500, 502]) + expect(await provisionWorkspaceToken(opts(fetchFn), 'u1')).toEqual({ status: 'failed', reason: 'broker 502' }) + expect(calls).toHaveLength(2) + }) + + it('retries a 5xx and succeeds on the second attempt', async () => { + const { fetchFn, calls } = makeFetch([503, 200]) + expect(await provisionWorkspaceToken(opts(fetchFn), 'u1')).toEqual({ status: 'ok' }) + expect(calls).toHaveLength(2) + }) + + it('retries a network error then fails as unreachable', async () => { + const { fetchFn, calls } = makeFetch(['throw', 'throw']) + expect(await provisionWorkspaceToken(opts(fetchFn), 'u1')).toEqual({ + status: 'failed', + reason: 'broker unreachable', + }) + expect(calls).toHaveLength(2) + }) + + it('maps a timeout to failed (broker timeout)', async () => { + const { fetchFn } = makeFetch(['timeout']) + expect(await provisionWorkspaceToken(opts(fetchFn, { maxAttempts: 1 }), 'u1')).toEqual({ + status: 'failed', + reason: 'broker timeout', + }) + }) + + it('normalizes a broker URL with no trailing slash', async () => { + const { fetchFn, calls } = makeFetch([200]) + await provisionWorkspaceToken(opts(fetchFn, { brokerUrl: 'https://broker.example' }), 'u1') + expect(calls[0].url).toBe('https://broker.example/github/provision') + }) + + it('never carries the end-user Better-Auth token (only the service token)', async () => { + const { fetchFn, calls } = makeFetch([200]) + await provisionWorkspaceToken(opts(fetchFn), 'u1') + expect(calls[0].headers.authorization).toBe('Bearer svc-token') + }) +}) diff --git a/backend/src/coding-agent/provision.ts b/backend/src/coding-agent/provision.ts new file mode 100644 index 000000000..f3649fd76 --- /dev/null +++ b/backend/src/coding-agent/provision.ts @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Ask the coding-agent broker to provision a fresh `GH_TOKEN` for a developer. + * + * The broker holds the GitHub App credentials and each user's refresh token; it + * mints a short-lived user-to-server access token and writes it into that user's + * workspace Secret, so Cline acts *as the developer* on GitHub. We authenticate + * to the broker with a shared service token and identify the developer with the + * Better-Auth `user.id` — the broker never sees the end-user's Better-Auth token. + * + * The call is bounded by a timeout and retried on transient (network / 5xx) + * failures; terminal outcomes (409 not-connected, 501 disabled, other 4xx) are + * never retried. + */ + +export type ProvisionResult = + | { status: 'ok' } + /** The developer has not connected GitHub yet (broker 409) — prompt them to. */ + | { status: 'not_connected' } + /** The broker is reachable but provisioning is disabled (broker 501) — proceed read-only. */ + | { status: 'disabled' } + /** The broker could not provision (timeout / network / 5xx / misconfig) — not the dev's fault. */ + | { status: 'failed'; reason: string } + +export type ProvisionOptions = { + /** Broker base URL, e.g. https://coding-agent-broker.thunderbird.net */ + brokerUrl: string + /** Shared service token authenticating Thunderbolt → broker. */ + serviceToken: string + fetchFn: typeof fetch + /** Per-attempt timeout (ms). Default 8000. */ + timeoutMs?: number + /** Total attempts including the first. Default 2. */ + maxAttempts?: number +} + +const isRetryableStatus = (status: number): boolean => status >= 500 + +/** + * POST /github/provision on the broker for `userId`. Maps the broker's response + * to a {@link ProvisionResult}; the access token itself never transits here — it + * lands directly in the workspace Secret. The reason string is kept body-free + * (only the numeric status) so a broker error body can never reach logs. + */ +export const provisionWorkspaceToken = async (opts: ProvisionOptions, userId: string): Promise => { + const url = `${opts.brokerUrl.trim().replace(/\/+$/, '')}/github/provision` + const timeoutMs = opts.timeoutMs ?? 8000 + const maxAttempts = opts.maxAttempts ?? 2 + let lastReason = 'broker unreachable' + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let res: Awaited> + try { + res = await opts.fetchFn(url, { + method: 'POST', + headers: { + authorization: `Bearer ${opts.serviceToken}`, + 'x-tb-user-id': userId, + }, + signal: AbortSignal.timeout(timeoutMs), + }) + } catch (err) { + // Network error / timeout — transient, retry until attempts exhausted. + lastReason = err instanceof Error && err.name === 'TimeoutError' ? 'broker timeout' : 'broker unreachable' + continue + } + + if (res.ok) { + return { status: 'ok' } + } + if (res.status === 409) { + return { status: 'not_connected' } + } + if (res.status === 501) { + return { status: 'disabled' } + } + lastReason = `broker ${res.status}` + if (!isRetryableStatus(res.status)) { + return { status: 'failed', reason: lastReason } + } + // retryable 5xx — fall through to the next attempt + } + + return { status: 'failed', reason: lastReason } +} diff --git a/backend/src/coding-agent/proxy.test.ts b/backend/src/coding-agent/proxy.test.ts new file mode 100644 index 000000000..63b68965d --- /dev/null +++ b/backend/src/coding-agent/proxy.test.ts @@ -0,0 +1,206 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import { CodingAgentProxy, type UpstreamSocket } from './proxy' + +const makeFakeUpstream = () => { + const listeners: Record void)[]> = {} + const sent: string[] = [] + let closedWith: { code?: number; reason?: string } | null = null + let throwOnSend = false + + const sock: UpstreamSocket = { + readyState: 0, // CONNECTING + send: (data) => { + if (throwOnSend) { + throw new Error('send failed') + } + sent.push(data) + }, + close: (code, reason) => { + closedWith = { code, reason } + }, + addEventListener: (type, listener) => { + ;(listeners[type] ??= []).push(listener) + }, + } + + return { + sock, + sent, + get closedWith() { + return closedWith + }, + setThrowOnSend: (v: boolean) => { + throwOnSend = v + }, + emit: (type: string, event: { data?: unknown; code?: number; reason?: string } = {}) => + (listeners[type] ?? []).forEach((l) => l(event)), + open: () => { + sock.readyState = 1 + ;(listeners.open ?? []).forEach((l) => l({})) + }, + } +} + +const makeProxy = (opts: { queueMessages?: number; queueBytes?: number } = {}) => { + const up = makeFakeUpstream() + const toClient: string[] = [] + const closes: { code: number; reason: string }[] = [] + const logs: { event: string; detail: Record }[] = [] + let timerFn: (() => void) | null = null + const proxy = new CodingAgentProxy({ + send: (d) => toClient.push(d), + onClose: (code, reason) => closes.push({ code, reason }), + onLog: (event, detail) => logs.push({ event, detail }), + upstreamUrl: 'wss://workspace.example/?token=x', + createUpstream: () => up.sock, + queueMessages: opts.queueMessages, + queueBytes: opts.queueBytes, + setTimer: (fn) => { + timerFn = fn + return 1 + }, + clearTimer: () => { + timerFn = null + }, + }) + return { proxy, up, toClient, closes, logs, fireTimer: () => timerFn?.(), hasTimer: () => timerFn !== null } +} + +describe('CodingAgentProxy', () => { + it('buffers client frames until the upstream opens, then flushes them in order', () => { + const { proxy, up } = makeProxy() + proxy.handleClientMessage('a') + proxy.handleClientMessage('b') + expect(up.sent).toEqual([]) + up.open() + expect(up.sent).toEqual(['a', 'b']) + }) + + it('forwards client frames immediately once open', () => { + const { proxy, up } = makeProxy() + up.open() + proxy.handleClientMessage('c') + expect(up.sent).toEqual(['c']) + }) + + it('pipes upstream messages back to the client', () => { + const { up, toClient } = makeProxy() + up.open() + up.emit('message', { data: 'from-agent' }) + expect(toClient).toEqual(['from-agent']) + }) + + it('stringifies non-string upstream messages', () => { + const { up, toClient } = makeProxy() + up.open() + up.emit('message', { data: { hello: 1 } }) + expect(toClient).toEqual(['{"hello":1}']) + }) + + it('on upstream close, passes a sendable code with a GENERIC reason and logs the real one', () => { + const { up, closes, logs } = makeProxy() + up.emit('close', { code: 1011, reason: 'internal shim detail' }) + expect(closes).toEqual([{ code: 1011, reason: 'upstream closed' }]) // reason NOT relayed verbatim + expect(logs.some((l) => l.detail.reason === 'internal shim detail')).toBe(true) // logged server-side + }) + + it('substitutes a reserved abnormal close code (1006) with 1011', () => { + const { up, closes } = makeProxy() + up.emit('close', { code: 1006 }) + expect(closes[0].code).toBe(1011) + }) + + it('defaults a missing upstream close code to 1011', () => { + const { up, closes } = makeProxy() + up.emit('close', {}) + expect(closes).toEqual([{ code: 1011, reason: 'upstream closed' }]) + }) + + it('on upstream error, closes the client 1011', () => { + const { up, closes } = makeProxy() + up.emit('error') + expect(closes).toEqual([{ code: 1011, reason: 'upstream error' }]) + }) + + it('arms a connect timer and clears it on open', () => { + const { up, hasTimer } = makeProxy() + expect(hasTimer()).toBe(true) + up.open() + expect(hasTimer()).toBe(false) + }) + + it('connect timeout tears down with 1011 when the upstream never opens', () => { + const { up, closes, fireTimer } = makeProxy() + fireTimer() + expect(closes).toEqual([{ code: 1011, reason: 'upstream unavailable' }]) + expect(up.closedWith).not.toBeNull() + }) + + it('caps the pre-connect queue and overflows with 4008', () => { + const { proxy, up, closes } = makeProxy({ queueMessages: 2 }) + proxy.handleClientMessage('a') + proxy.handleClientMessage('b') + proxy.handleClientMessage('c') // exceeds cap of 2 + expect(closes).toEqual([{ code: 4008, reason: 'pre-connect queue overflow' }]) + expect(up.closedWith).not.toBeNull() + }) + + it('dispose() closes the upstream and suppresses the close callback', () => { + const { proxy, up, closes } = makeProxy() + proxy.dispose() + expect(up.closedWith).toEqual({ code: 1000, reason: 'client disconnected' }) + up.emit('close', { code: 1000 }) + expect(closes).toEqual([]) // suppressed after dispose + }) + + it('ignores client frames and upstream messages after dispose', () => { + const { proxy, up, toClient } = makeProxy() + up.open() + proxy.dispose() + proxy.handleClientMessage('x') + up.emit('message', { data: 'y' }) + expect(up.sent).toEqual([]) + expect(toClient).toEqual([]) + }) + + it('open after dispose closes the upstream without flushing buffered frames', () => { + const { proxy, up } = makeProxy() + proxy.handleClientMessage('a') + proxy.dispose() + up.open() + expect(up.sent).toEqual([]) + }) + + it('swallows an upstream send throw without crashing', () => { + const { proxy, up } = makeProxy() + up.open() + up.setThrowOnSend(true) + expect(() => proxy.handleClientMessage('x')).not.toThrow() + }) + + for (const reserved of [1005, 1006, 1015]) { + it(`substitutes reserved abnormal close code ${reserved} with 1011`, () => { + const { up, closes } = makeProxy() + up.emit('close', { code: reserved }) + expect(closes[0].code).toBe(1011) + }) + } + + it('passes non-reserved upstream close codes through unchanged', () => { + const { up, closes } = makeProxy() + up.emit('close', { code: 4567 }) // app-range code survives + expect(closes[0].code).toBe(4567) + }) + + it('overflows on the byte budget (queueBytes), counting Buffer.byteLength', () => { + const { proxy, closes } = makeProxy({ queueMessages: 100, queueBytes: 4 }) + proxy.handleClientMessage('abc') // 3 bytes — within budget + expect(closes).toEqual([]) + proxy.handleClientMessage('de') // 3 + 2 = 5 > 4 — overflow + expect(closes).toEqual([{ code: 4008, reason: 'pre-connect queue overflow' }]) + }) +}) diff --git a/backend/src/coding-agent/proxy.ts b/backend/src/coding-agent/proxy.ts new file mode 100644 index 000000000..9cf9531af --- /dev/null +++ b/backend/src/coding-agent/proxy.ts @@ -0,0 +1,208 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * A thin bidirectional bridge between a connected Thunderbolt client socket and + * the workspace shim's ACP WebSocket. Our agent is already a complete ACP + * endpoint, so this just pipes raw frames both ways. Client→upstream frames that + * arrive before the upstream finishes connecting are buffered (bounded) and + * flushed in order on open. Mirrors the discipline of the universal relay in + * `backend/src/proxy/ws.ts`: a single `closing` flag guards every listener, the + * pre-connect queue is capped, a connect timeout bounds a hung upstream, and the + * upstream-provided close reason is never relayed to the client. + */ + +const wsReadyStateOpen = 1 + +const defaultConnectTimeoutMs = 10_000 +const defaultQueueBytes = 256 * 1024 +const defaultQueueMessages = 64 + +/** Upstream error / abnormal close reported to the client (RFC 6455 1011). */ +const wsCloseUpstreamError = 1011 +/** Pre-connect queue exceeded its byte/message budget (app range). */ +const wsCloseQueueOverflow = 4008 + +export type UpstreamEvent = { data?: unknown; code?: number; reason?: string } + +/** Minimal surface of a client WebSocket the proxy needs (Bun/global WebSocket). */ +export type UpstreamSocket = { + readyState: number + send: (data: string) => void + close: (code?: number, reason?: string) => void + // `type` is a literal union so an event-name typo is a compile error. + addEventListener: (type: 'open' | 'message' | 'close' | 'error', listener: (event: UpstreamEvent) => void) => void +} + +export type UpstreamFactory = (url: string) => UpstreamSocket + +export type CodingAgentProxyDeps = { + /** Send a frame down to the connected Thunderbolt client. */ + send: (data: string) => void + /** Tear down the client socket with this code + a (generic, leak-safe) reason. */ + onClose: (code: number, reason: string) => void + /** Server-side structured logging hook (real upstream code/reason go here, never to the client). */ + onLog?: (event: string, detail: Record) => void + /** Workspace shim ACP endpoint (may carry the shim token as a query param). */ + upstreamUrl: string + /** Injectable upstream factory; defaults to the global WebSocket. May throw (caller wraps). */ + createUpstream?: UpstreamFactory + connectTimeoutMs?: number + queueBytes?: number + queueMessages?: number + /** Injectable timers (tests); default to global setTimeout/clearTimeout. */ + setTimer?: (fn: () => void, ms: number) => unknown + clearTimer?: (handle: unknown) => void +} + +const defaultFactory: UpstreamFactory = (url) => new WebSocket(url) as unknown as UpstreamSocket + +/** + * Bridge one client connection to the workspace shim. Construct after auth + + * provisioning succeed; feed client frames via {@link handleClientMessage} and + * tear down with {@link dispose}. The constructor opens the upstream socket and + * may throw synchronously (invalid URL) — the caller must wrap construction. + */ +export class CodingAgentProxy { + private readonly upstream: UpstreamSocket + private pending: string[] = [] + private pendingBytes = 0 + private upstreamReady = false + private closing = false + private readonly queueBytes: number + private readonly queueMessages: number + private readonly clearTimer: (handle: unknown) => void + private connectTimer: unknown + + constructor(private readonly deps: CodingAgentProxyDeps) { + const factory = deps.createUpstream ?? defaultFactory + this.queueBytes = deps.queueBytes ?? defaultQueueBytes + this.queueMessages = deps.queueMessages ?? defaultQueueMessages + const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms)) + this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType)) + + this.upstream = factory(deps.upstreamUrl) + + this.connectTimer = setTimer(() => { + if (this.closing || this.upstreamReady) { + return + } + this.deps.onLog?.('coding-agent upstream connect timeout', {}) + this.teardown(wsCloseUpstreamError, 'upstream unavailable') + }, deps.connectTimeoutMs ?? defaultConnectTimeoutMs) + + this.upstream.addEventListener('open', () => { + if (this.closing) { + this.safeUpstreamClose(1000) + return + } + this.upstreamReady = true + this.clearConnectTimer() + for (const frame of this.pending) { + this.trySend(frame) + } + this.pending = [] + this.pendingBytes = 0 + }) + + this.upstream.addEventListener('message', (event) => { + if (this.closing) { + return + } + const data = event.data + this.deps.send(typeof data === 'string' ? data : JSON.stringify(data)) + }) + + this.upstream.addEventListener('close', (event) => { + if (this.closing) { + return + } + this.closing = true + this.clearConnectTimer() + // Log the real upstream code/reason server-side; never relay the reason to the client. + this.deps.onLog?.('coding-agent upstream closed', { code: event.code, reason: event.reason }) + this.deps.onClose(sendableCloseCode(event.code ?? wsCloseUpstreamError), 'upstream closed') + }) + + this.upstream.addEventListener('error', () => { + if (this.closing) { + return + } + this.closing = true + this.clearConnectTimer() + this.deps.onLog?.('coding-agent upstream error', {}) + this.deps.onClose(wsCloseUpstreamError, 'upstream error') + }) + } + + /** Forward a frame from the client to the workspace, buffering (bounded) until upstream is open. */ + handleClientMessage(frame: string): void { + if (this.closing) { + return + } + if (this.upstreamReady && this.upstream.readyState === wsReadyStateOpen) { + this.trySend(frame) + return + } + const bytes = Buffer.byteLength(frame) + if (this.pending.length + 1 > this.queueMessages || this.pendingBytes + bytes > this.queueBytes) { + this.deps.onLog?.('coding-agent pre-connect queue overflow', { + frames: this.pending.length, + bytes: this.pendingBytes, + }) + this.teardown(wsCloseQueueOverflow, 'pre-connect queue overflow') + return + } + this.pending.push(frame) + this.pendingBytes += bytes + } + + /** Close the upstream. Idempotent; does NOT call onClose (the client is already gone). */ + dispose(): void { + if (this.closing) { + return + } + this.closing = true + this.clearConnectTimer() + this.safeUpstreamClose(1000, 'client disconnected') + } + + /** Internal teardown that also closes the client (timeout / overflow). */ + private teardown(code: number, reason: string): void { + if (this.closing) { + return + } + this.closing = true + this.clearConnectTimer() + this.safeUpstreamClose(1000, reason) + this.deps.onClose(code, reason) + } + + private trySend(frame: string): void { + try { + this.upstream.send(frame) + } catch { + // upstream gone mid-send; the close/error listener will tear down. + } + } + + private safeUpstreamClose(code?: number, reason?: string): void { + try { + this.upstream.close(code, reason) + } catch { + // already closed + } + } + + private clearConnectTimer(): void { + if (this.connectTimer !== undefined) { + this.clearTimer(this.connectTimer) + this.connectTimer = undefined + } + } +} + +/** RFC 6455 reserved codes (1005/1006/1015) cannot be sent by an endpoint — substitute. */ +const sendableCloseCode = (code: number): number => + code === 1005 || code === 1006 || code === 1015 ? wsCloseUpstreamError : code diff --git a/backend/src/coding-agent/routes.test.ts b/backend/src/coding-agent/routes.test.ts new file mode 100644 index 000000000..011cef0a3 --- /dev/null +++ b/backend/src/coding-agent/routes.test.ts @@ -0,0 +1,209 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for the coding-agent WS handlers. They call the exported + * handleCodingAgent{Open,Message,Close} functions directly against a fake `ws` + + * injected auth/provision/upstream — no bound port, no DB-backed app, exact close + * codes. (The real WS-upgrade machinery is exercised repo-wide by + * haystack/routes.test.ts; spinning a server per close-code here only added CI + * wall-clock under the 5x backend run.) + * + * Close-code contract: 4001 unauthorized, 4002 github-not-connected, 4003 + * provisioning failed / not configured. + */ + +import type { Auth } from '@/auth/elysia-plugin' +import { createTestSettings } from '@/test-utils/settings' +import { encodeWsBearer } from '@shared/ws-bearer' +import { describe, expect, it } from 'bun:test' +import type { ProvisionResult } from './provision' +import type { UpstreamSocket } from './proxy' +import { + createCodingAgentRoutes, + handleCodingAgentClose, + handleCodingAgentMessage, + handleCodingAgentOpen, + type CodingAgentOpenCtx, +} from './routes' + +const noopLog = { info() {}, warn() {}, error() {}, debug() {} } as unknown as CodingAgentOpenCtx['log'] +const userAuth = { api: { getSession: async () => ({ user: { id: 'u1', isAnonymous: false } }) } } as unknown as Auth +const fakeUpstream = (): UpstreamSocket => ({ readyState: 0, send() {}, close() {}, addEventListener() {} }) + +const bearerHeader = `thunderbolt.v1, thunderbolt.bearer.${encodeWsBearer('tok')}` + +const makeWs = (opts: { data?: Record; subprotocol?: string } = {}) => { + const request = new Request('http://localhost/v1/coding-agent/ws', { + headers: { 'sec-websocket-protocol': opts.subprotocol ?? bearerHeader }, + }) + const data: Record = { request, ...opts.data } + const sends: string[] = [] + const closes: { code?: number; reason?: string }[] = [] + const ws = { + data, + send: (p: string) => sends.push(p), + close: (code?: number, reason?: string) => closes.push({ code, reason }), + } + return { ws, data, sends, closes } +} + +const ctx = (over: Partial = {}): CodingAgentOpenCtx => ({ + auth: userAuth, + settings: createTestSettings({ + codingAgentWorkspaceWsUrl: 'wss://ws.test/?t=x', + codingAgentBrokerUrl: 'https://broker.test', + codingAgentServiceToken: 'svc', + }), + fetchFn: (async () => new Response('', { status: 200 })) as unknown as typeof fetch, + log: noopLog, + provision: async () => ({ status: 'ok' }), + createUpstream: fakeUpstream, + ...over, +}) + +describe('handleCodingAgentOpen — auth', () => { + it('closes 4001 when no bearer subprotocol is offered', async () => { + const { ws, closes } = makeWs({ subprotocol: 'thunderbolt.v1' }) + await handleCodingAgentOpen(ws, ctx()) + expect(closes).toEqual([{ code: 4001, reason: 'unauthorized' }]) + }) + + it('closes 4001 when the session is unauthenticated', async () => { + const noUserAuth = { api: { getSession: async () => null } } as unknown as Auth + const { ws, closes } = makeWs() + await handleCodingAgentOpen(ws, ctx({ auth: noUserAuth })) + expect(closes).toEqual([{ code: 4001, reason: 'unauthorized' }]) + }) +}) + +describe('handleCodingAgentOpen — provisioning', () => { + it("provision 'ok' constructs the proxy and leaves the socket open", async () => { + const { ws, data, closes } = makeWs() + await handleCodingAgentOpen(ws, ctx({ provision: async () => ({ status: 'ok' }) })) + expect(data.proxy).toBeDefined() + expect(closes).toEqual([]) + handleCodingAgentClose(ws) // dispose → clears the proxy's connect timer + }) + + it("provision 'disabled' proceeds read-only without closing", async () => { + const { ws, data, closes } = makeWs() + await handleCodingAgentOpen(ws, ctx({ provision: async () => ({ status: 'disabled' }) })) + expect(data.proxy).toBeDefined() + expect(closes).toEqual([]) + handleCodingAgentClose(ws) + }) + + it("provision 'not_connected' closes 4002", async () => { + const { ws, data, closes } = makeWs() + await handleCodingAgentOpen(ws, ctx({ provision: async () => ({ status: 'not_connected' }) })) + expect(closes).toEqual([{ code: 4002, reason: 'github not connected' }]) + expect(data.proxy).toBeUndefined() + }) + + it('a provisioning throw closes 4003', async () => { + const { ws, data, closes } = makeWs() + await handleCodingAgentOpen( + ws, + ctx({ + provision: async () => { + throw new Error('broker down') + }, + }), + ) + expect(closes).toEqual([{ code: 4003, reason: 'provisioning failed' }]) + expect(data.proxy).toBeUndefined() + }) + + it('an out-of-union provision status hits the exhaustive default and closes 4003', async () => { + const { ws, closes } = makeWs() + await handleCodingAgentOpen(ws, ctx({ provision: async () => ({ status: 'bogus' }) as unknown as ProvisionResult })) + expect(closes).toEqual([{ code: 4003, reason: 'provisioning failed' }]) + }) + + it('closes 4003 (not configured) when the workspace endpoint is empty', async () => { + const { ws, closes } = makeWs() + await handleCodingAgentOpen(ws, ctx({ settings: createTestSettings({ codingAgentWorkspaceWsUrl: '' }) })) + expect(closes).toEqual([{ code: 4003, reason: 'coding agent not configured' }]) + }) +}) + +describe('handleCodingAgentOpen — upstream', () => { + it('client disconnect during open aborts before constructing the upstream', async () => { + let createUpstreamCalled = false + const { ws, data, closes } = makeWs({ data: { clientClosed: true } }) + await handleCodingAgentOpen( + ws, + ctx({ + createUpstream: () => { + createUpstreamCalled = true + return fakeUpstream() + }, + }), + ) + expect(createUpstreamCalled).toBe(false) + expect(data.proxy).toBeUndefined() + expect(closes).toEqual([]) + }) + + it('upstream construction failure closes 4003', async () => { + const { ws, data, closes } = makeWs() + await handleCodingAgentOpen( + ws, + ctx({ + createUpstream: () => { + throw new Error('bad url') + }, + }), + ) + expect(closes).toEqual([{ code: 4003, reason: 'upstream connect failed' }]) + expect(data.proxy).toBeUndefined() + }) +}) + +describe('handleCodingAgentMessage / handleCodingAgentClose', () => { + it('message: no-op when no proxy is attached', () => { + const { ws } = makeWs() + expect(() => handleCodingAgentMessage(ws, '{}')).not.toThrow() + }) + + it('message: forwards string frames verbatim and JSON-encodes object frames', () => { + const received: string[] = [] + const { ws, data } = makeWs() + data.proxy = { handleClientMessage: (f: string) => received.push(f) } + handleCodingAgentMessage(ws, 'raw-string') + handleCodingAgentMessage(ws, { a: 1 }) + expect(received).toEqual(['raw-string', '{"a":1}']) + }) + + it('close: disposes the proxy, marks clientClosed, clears the slot', () => { + let disposed = 0 + const { ws, data } = makeWs() + data.proxy = { dispose: () => (disposed += 1) } + handleCodingAgentClose(ws) + expect(disposed).toBe(1) + expect(data.clientClosed).toBe(true) + expect(data.proxy).toBeUndefined() + }) + + it('close: no-op when no proxy is attached', () => { + const { ws } = makeWs() + expect(() => handleCodingAgentClose(ws)).not.toThrow() + }) +}) + +describe('createCodingAgentRoutes', () => { + it('mounts the route and exercises the single-shared-workspace WARN branch (broker + workspace set)', () => { + const settings = createTestSettings({ + codingAgentWorkspaceWsUrl: 'wss://ws.test', + codingAgentBrokerUrl: 'https://broker.test', + codingAgentServiceToken: 'svc', + }) + expect(createCodingAgentRoutes(settings, userAuth)).toBeDefined() + }) + + it('mounts the route without the WARN branch when the coding agent is unconfigured', () => { + expect(createCodingAgentRoutes(createTestSettings(), userAuth)).toBeDefined() + }) +}) diff --git a/backend/src/coding-agent/routes.ts b/backend/src/coding-agent/routes.ts new file mode 100644 index 000000000..8d6df48ed --- /dev/null +++ b/backend/src/coding-agent/routes.ts @@ -0,0 +1,238 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { registerAgentProvider } from '@/agents' +import type { Auth } from '@/auth/elysia-plugin' +import { authorizeWsBearer } from '@/auth/ws-bearer-auth' +import { createStandaloneLogger } from '@/config/logger' +import type { Settings } from '@/config/settings' +import { safeErrorHandler } from '@/middleware/error-handling' +import type { User } from '@shared/types/auth' +import { Elysia } from 'elysia' +import { createCodingAgentProvider } from './provider' +import { provisionWorkspaceToken, type ProvisionOptions, type ProvisionResult } from './provision' +import { CodingAgentProxy, type UpstreamFactory } from './proxy' + +/** Carrier subprotocol echoed back so strict WS clients complete the upgrade. */ +const wsCarrierSubprotocol = 'thunderbolt.v1' + +/** Auth failed (missing/invalid/anonymous bearer). */ +const wsCloseUnauthorized = 4001 +/** The developer has not connected GitHub — the UI should prompt `github_connect`. */ +const wsCloseGithubNotConnected = 4002 +/** Provisioning failed (broker/workspace misconfigured, or the broker could not mint a token). */ +const wsCloseProvisionFailed = 4003 + +/** Per-connection state stashed on `ws.data`; Elysia's WS context doesn't surface these. */ +type WsData = { request?: Request; proxy?: CodingAgentProxy; clientClosed?: boolean } +const wsData = (ws: { data: unknown }): WsData => ws.data as WsData + +/** Minimal WS surface the handlers use — structural, so they're unit-testable with a fake. */ +type CodingAgentWs = { data: unknown; send: (payload: string) => void; close: (code?: number, reason?: string) => void } + +/** Injected dependencies for the WS handlers (closure-captured by the route, overridable in tests). */ +export type CodingAgentOpenCtx = { + auth: Auth + settings: Settings + fetchFn: typeof fetch + log: ReturnType + /** Injectable upstream WS factory; defaults to the global WebSocket inside the proxy. */ + createUpstream?: UpstreamFactory + /** Injectable provisioning seam; defaults to the real broker call. */ + provision?: (opts: ProvisionOptions, userId: string) => Promise +} + +const safeWsClose = (ws: { close: (code?: number, reason?: string) => void }, code: number, reason: string): void => { + try { + ws.close(code, reason) + } catch { + // already closed + } +} + +export type CodingAgentDeps = { + fetchFn?: typeof fetch + /** Injectable upstream WS factory (tests); defaults to the global WebSocket. */ + createUpstream?: UpstreamFactory +} + +/** + * WS `open` handler: authenticate the developer, provision their GitHub token via + * the broker, then construct the proxy to the workspace shim. Pure of Elysia — + * takes a minimal `ws` + injected `ctx` so every branch is unit-testable without + * a bound port or a real shim. Auth + provisioning are wrapped so a broker/network + * failure closes the socket rather than leaking out (Elysia `onError` does not + * cover WS lifecycle callbacks). + */ +export const handleCodingAgentOpen = async (ws: CodingAgentWs, ctx: CodingAgentOpenCtx): Promise => { + const { settings, log } = ctx + const data = wsData(ws) + + const subprotocolHeader = data.request?.headers.get('sec-websocket-protocol') ?? null + const user: User | null = await authorizeWsBearer(ctx.auth, subprotocolHeader) + if (!user) { + safeWsClose(ws, wsCloseUnauthorized, 'unauthorized') + return + } + + const upstreamUrl = settings.codingAgentWorkspaceWsUrl.trim() + if (upstreamUrl.length === 0) { + log.warn({ userId: user.id }, 'coding-agent: workspace endpoint not configured') + safeWsClose(ws, wsCloseProvisionFailed, 'coding agent not configured') + return + } + + // Provision this developer's GH_TOKEN before opening the session. Returns null + // when the attempt threw and the socket was already closed. + const brokerConfigured = settings.codingAgentBrokerUrl.length > 0 + const tryProvision = async (): Promise => { + const provision = ctx.provision ?? provisionWorkspaceToken + try { + return await provision( + { + brokerUrl: settings.codingAgentBrokerUrl, + serviceToken: settings.codingAgentServiceToken, + fetchFn: ctx.fetchFn, + }, + user.id, + ) + } catch (err) { + log.error({ userId: user.id, err }, 'coding-agent provisioning threw') + safeWsClose(ws, wsCloseProvisionFailed, 'provisioning failed') + return null + } + } + + const result: ProvisionResult | null = brokerConfigured ? await tryProvision() : { status: 'ok' } + if (result === null) { + return // already closed in the catch + } + switch (result.status) { + case 'ok': + if (brokerConfigured) { + log.info({ userId: user.id }, 'coding-agent provisioned') + } + break + case 'disabled': + log.warn({ userId: user.id }, 'coding-agent broker provisioning disabled; proceeding read-only') + break + case 'not_connected': + log.warn({ userId: user.id }, 'coding-agent: github not connected') + safeWsClose(ws, wsCloseGithubNotConnected, 'github not connected') + return + case 'failed': + log.error({ userId: user.id, reason: result.reason }, 'coding-agent provisioning failed') + safeWsClose(ws, wsCloseProvisionFailed, 'provisioning failed') + return + default: { + const exhaustive: never = result + log.error({ userId: user.id, result: exhaustive }, 'coding-agent: unknown provision result') + safeWsClose(ws, wsCloseProvisionFailed, 'provisioning failed') + return + } + } + + // The client may have disconnected during the awaited auth/provision above; + // `close` would have fired before the proxy existed. Don't open the upstream. + if (data.clientClosed) { + log.debug({ userId: user.id }, 'coding-agent: client closed during open; aborting') + return + } + + try { + data.proxy = new CodingAgentProxy({ + send: (payload) => { + try { + ws.send(payload) + } catch { + // client gone + } + }, + onClose: (code, reason) => safeWsClose(ws, code, reason), + onLog: (event, detail) => log.warn({ userId: user.id, ...detail }, event), + upstreamUrl, + createUpstream: ctx.createUpstream, + }) + } catch (err) { + log.error({ userId: user.id, err }, 'coding-agent: upstream connect failed') + safeWsClose(ws, wsCloseProvisionFailed, 'upstream connect failed') + return + } + log.debug({ userId: user.id }, 'coding-agent ws opened') +} + +/** WS `message` handler: forward the frame to the proxy (string verbatim, objects JSON-encoded). */ +export const handleCodingAgentMessage = (ws: CodingAgentWs, message: string | object): void => { + const proxy = wsData(ws).proxy + if (!proxy) { + return + } + proxy.handleClientMessage(typeof message === 'string' ? message : JSON.stringify(message)) +} + +/** WS `close` handler: mark closed (so a racing open() aborts) and dispose the proxy. */ +export const handleCodingAgentClose = (ws: CodingAgentWs): void => { + const data = wsData(ws) + data.clientClosed = true + data.proxy?.dispose() + data.proxy = undefined +} + +/** + * Mount the coding-agent managed-acp routes. + * + * - Registers the provider into the discovery registry (idempotent on id). + * - Exposes `WS /v1/coding-agent/ws`, delegating to the exported handlers above. + * + * Provisioning is the multi-user crux: when the broker is configured it mints a + * user-to-server token for *this* developer (Better-Auth `user.id`) and injects + * it into their workspace Secret before the session starts, so Cline commits as + * them. When the broker isn't configured the proxy still runs (read-only / no-push + * flows); a 409 closes 4002 so the UI can prompt the developer to connect GitHub. + * + * IMPORTANT (single-workspace caveat): today all sessions proxy to one shared + * `CODING_AGENT_WORKSPACE_WS_URL`. Per-user workspace routing does not exist yet, + * so concurrent users share one workspace (and the last-provisioned GH_TOKEN). + * This is single-user / PoC-safe only — a startup WARN is emitted when both the + * workspace and broker are configured. + */ +export const createCodingAgentRoutes = (settings: Settings, auth: Auth, deps?: CodingAgentDeps) => { + registerAgentProvider(createCodingAgentProvider()) + + // One logger for the route's lifetime — do NOT construct per connection. + const log = createStandaloneLogger(settings) + const ctx: CodingAgentOpenCtx = { + auth, + settings, + fetchFn: deps?.fetchFn ?? globalThis.fetch, + log, + createUpstream: deps?.createUpstream, + } + + if (settings.codingAgentWorkspaceWsUrl.trim().length > 0 && settings.codingAgentBrokerUrl.length > 0) { + log.warn( + 'coding-agent: per-user GH_TOKEN is provisioned, but all sessions proxy to a single shared ' + + 'CODING_AGENT_WORKSPACE_WS_URL. Until per-user workspace routing exists, concurrent users share one ' + + 'workspace and the last-provisioned token. Treat as single-user / PoC.', + ) + } + + return new Elysia({ name: 'coding-agent-routes', prefix: '/coding-agent' }).onError(safeErrorHandler).ws('/ws', { + upgrade({ request, set }) { + const subprotocolHeader = request.headers.get('sec-websocket-protocol') + if (subprotocolHeader?.split(',').some((entry) => entry.trim() === wsCarrierSubprotocol)) { + set.headers['sec-websocket-protocol'] = wsCarrierSubprotocol + } + }, + open(ws) { + return handleCodingAgentOpen(ws as unknown as CodingAgentWs, ctx) + }, + message(ws, message) { + handleCodingAgentMessage(ws as unknown as CodingAgentWs, message as string | object) + }, + close(ws) { + handleCodingAgentClose(ws as unknown as CodingAgentWs) + }, + }) +} diff --git a/backend/src/config/settings.ts b/backend/src/config/settings.ts index 966a655ae..943225c1d 100644 --- a/backend/src/config/settings.ts +++ b/backend/src/config/settings.ts @@ -121,6 +121,14 @@ const settingsSchema = z // JSON array of pipeline descriptors: [{id, name, pipelineName, pipelineId, description?, icon?}]. // `id` is the public slug; `pipelineName` is the Deepset URL slug; `pipelineId` is the Deepset UUID. haystackPipelines: z.string().default(''), + // Coding-agent (self-hosted Cline) settings, consumed by the coding-agent provider/route. + // Workspace shim ACP endpoint the backend proxies to (ws:// or wss://; may carry the shim + // token as a query param). Empty = the agent is not advertised. + codingAgentWorkspaceWsUrl: z.string().trim().default(''), + // Broker base URL for per-user GitHub token provisioning. Empty = skip provisioning. + codingAgentBrokerUrl: z.string().trim().default(''), + // Shared service token authenticating Thunderbolt → broker (sent as a Bearer token). + codingAgentServiceToken: z.string().trim().default(''), }) .superRefine((data, ctx) => { if (data.powersyncUrl && data.powersyncJwtSecret.length < 32) { @@ -134,6 +142,24 @@ const settingsSchema = z input: '[REDACTED]', }) } + // Broker URL and service token are paired — one without the other is a misconfig that + // would otherwise fail opaquely at WS-connect time. Catch it at boot. + if (data.codingAgentBrokerUrl && data.codingAgentServiceToken.length === 0) { + ctx.addIssue({ + code: 'custom', + message: 'codingAgentServiceToken is required when codingAgentBrokerUrl is set', + path: ['codingAgentServiceToken'], + input: '[REDACTED]', + }) + } + if (data.codingAgentServiceToken && data.codingAgentBrokerUrl.length === 0) { + ctx.addIssue({ + code: 'custom', + message: 'codingAgentBrokerUrl is required when codingAgentServiceToken is set', + path: ['codingAgentBrokerUrl'], + input: '', + }) + } }) export type Settings = z.infer @@ -202,6 +228,9 @@ const parseSettings = (): Settings => { haystackApiKey: process.env.HAYSTACK_API_KEY || '', haystackWorkspace: process.env.HAYSTACK_WORKSPACE || '', haystackPipelines: process.env.HAYSTACK_PIPELINES || '', + codingAgentWorkspaceWsUrl: process.env.CODING_AGENT_WORKSPACE_WS_URL || '', + codingAgentBrokerUrl: process.env.CODING_AGENT_BROKER_URL || '', + codingAgentServiceToken: process.env.CODING_AGENT_SERVICE_TOKEN || '', } return settingsSchema.parse(env) diff --git a/backend/src/index.ts b/backend/src/index.ts index c0a47c804..fd00f0a43 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,6 +27,7 @@ import { createWaitlistRoutes } from '@/waitlist/routes' import { createAccountRoutes } from '@/api/account' import { createAgentsRoutes } from '@/agents' import { createHaystackRoutes } from '@/haystack' +import { createCodingAgentGithubRoutes, createCodingAgentRoutes } from '@/coding-agent' import { createConfigRoutes } from '@/api/config' import { createEncryptionRoutes } from '@/api/encryption' import { createPowerSyncRoutes } from '@/api/powersync' @@ -157,6 +158,8 @@ export const createApp = async (deps?: AppDeps) => { .use(createAccountRoutes(auth, database)) .use(createAgentsRoutes(auth)) .use(createHaystackRoutes(settings, auth, { fetchFn })) + .use(createCodingAgentRoutes(settings, auth, { fetchFn })) + .use(createCodingAgentGithubRoutes(settings, auth, { fetchFn })) ) } diff --git a/backend/src/test-utils/settings.ts b/backend/src/test-utils/settings.ts index 3f02959e4..cbd6572db 100644 --- a/backend/src/test-utils/settings.ts +++ b/backend/src/test-utils/settings.ts @@ -62,5 +62,8 @@ export const createTestSettings = (overrides: Partial = {}): Settings haystackApiKey: '', haystackWorkspace: '', haystackPipelines: '', + codingAgentWorkspaceWsUrl: '', + codingAgentBrokerUrl: '', + codingAgentServiceToken: '', ...overrides, }) diff --git a/src/integrations/coding-agent/api.ts b/src/integrations/coding-agent/api.ts new file mode 100644 index 000000000..fd150ddd0 --- /dev/null +++ b/src/integrations/coding-agent/api.ts @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Client calls to the backend coding-agent GitHub endpoints + * (`/v1/coding-agent/github/*`). The backend resolves the developer's `user.id` + * from the authenticated session and talks to the broker — the client never + * sends a user id, so the assistant cannot act as another user. + */ + +import type { HttpClient } from '@/lib/http' + +const requestTimeout = 10000 + +/** Mirrors `backend/src/coding-agent/github-routes.ts` GithubAuthorizeUrlDto. */ +export type GithubAuthorizeUrlResponse = + | { configured: false } + | { configured: true; status: 'ok'; url: string } + | { configured: true; status: 'disabled' } + | { configured: true; status: 'failed' } + +/** Mirrors `backend/src/coding-agent/github-routes.ts` GithubStatusDto. */ +export type GithubStatusResponse = + | { configured: false } + | { configured: true; status: 'ok'; connected: boolean } + | { configured: true; status: 'disabled' } + | { configured: true; status: 'failed' } + +export const getGithubAuthorizeUrl = (httpClient: HttpClient): Promise => + httpClient.get('coding-agent/github/authorize-url', { timeout: requestTimeout }).json() + +export const getGithubStatus = (httpClient: HttpClient): Promise => + httpClient.get('coding-agent/github/status', { timeout: requestTimeout }).json() diff --git a/src/integrations/coding-agent/tools.test.ts b/src/integrations/coding-agent/tools.test.ts new file mode 100644 index 000000000..1112088bd --- /dev/null +++ b/src/integrations/coding-agent/tools.test.ts @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createClient, type HttpClient } from '@/lib/http' +import { describe, expect, it } from 'bun:test' +import type { GithubAuthorizeUrlResponse, GithubStatusResponse } from './api' +import { createConfigs } from './tools' + +const jsonClient = ( + body: GithubAuthorizeUrlResponse | GithubStatusResponse, + capture?: (url: string) => void, +): HttpClient => + createClient({ + prefixUrl: 'http://test-api.local/v1', + fetch: async (input) => { + capture?.(input instanceof Request ? input.url : String(input)) + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }, + }) + +const tool = (client: HttpClient, name: 'github_connect' | 'github_status') => { + const config = createConfigs(client).find((t) => t.name === name) + if (!config) { + throw new Error(`tool ${name} not found`) + } + return config +} + +describe('createConfigs', () => { + it('exposes github_connect and github_status with empty (no-arg) schemas', () => { + const configs = createConfigs(jsonClient({ configured: false })) + expect(configs.map((c) => c.name).sort()).toEqual(['github_connect', 'github_status']) + // No model-supplied args: the parameters schema accepts only an empty object. + for (const c of configs) { + expect(c.parameters.safeParse({}).success).toBe(true) + expect(c.parameters.safeParse({ user_id: 'someone-else' }).success).toBe(false) + } + }) +}) + +describe('github_connect', () => { + it('hits the backend authorize-url endpoint and returns a clickable connect message', async () => { + let calledUrl = '' + const client = jsonClient( + { configured: true, status: 'ok', url: 'https://github.com/login/oauth/authorize?state=x' }, + (u) => (calledUrl = u), + ) + const result = await tool(client, 'github_connect').execute({}) + expect(calledUrl).toContain('/coding-agent/github/authorize-url') + expect(result).toEqual({ + url: 'https://github.com/login/oauth/authorize?state=x', + message: 'Connect your GitHub: https://github.com/login/oauth/authorize?state=x', + }) + }) + + it('explains when the coding agent is not configured', async () => { + const result = await tool(jsonClient({ configured: false }), 'github_connect').execute({}) + expect(result.connected).toBe(false) + expect(result.message).toContain('not configured') + }) + + it('explains a disabled broker', async () => { + const result = await tool(jsonClient({ configured: true, status: 'disabled' }), 'github_connect').execute({}) + expect(result.connected).toBe(false) + expect(result.message).toContain('disabled') + }) + + it('explains a broker failure', async () => { + const result = await tool(jsonClient({ configured: true, status: 'failed' }), 'github_connect').execute({}) + expect(result.connected).toBe(false) + expect(result.message).toContain('try again') + }) +}) + +describe('github_status', () => { + it('reports connected', async () => { + let calledUrl = '' + const client = jsonClient({ configured: true, status: 'ok', connected: true }, (u) => (calledUrl = u)) + const result = await tool(client, 'github_status').execute({}) + expect(calledUrl).toContain('/coding-agent/github/status') + expect(result.connected).toBe(true) + expect(result.message).toContain('connected') + }) + + it('reports not-connected and points at github_connect', async () => { + const result = await tool( + jsonClient({ configured: true, status: 'ok', connected: false }), + 'github_status', + ).execute({}) + expect(result.connected).toBe(false) + expect(result.message).toContain('github_connect') + }) + + it('explains not configured', async () => { + const result = await tool(jsonClient({ configured: false }), 'github_status').execute({}) + expect(result.connected).toBe(false) + expect(result.message).toContain('not configured') + }) + + it('explains a broker failure', async () => { + const result = await tool(jsonClient({ configured: true, status: 'failed' }), 'github_status').execute({}) + expect(result.connected).toBe(false) + expect(result.message).toContain('try again') + }) +}) diff --git a/src/integrations/coding-agent/tools.ts b/src/integrations/coding-agent/tools.ts new file mode 100644 index 000000000..bb85b5e01 --- /dev/null +++ b/src/integrations/coding-agent/tools.ts @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Built-in assistant tools for connecting / checking a developer's GitHub via + * the coding-agent broker: + * + * - `github_connect` → returns the per-user GitHub OAuth authorize URL to click. + * - `github_status` → reports whether GitHub is connected. + * + * The tools take NO arguments: the developer's identity is the authenticated + * session resolved server-side, so the model cannot connect/inspect another + * user's account. The broker call lives in the backend; these tools only shape a + * human-readable result for the chat. + */ + +import type { HttpClient } from '@/lib/http' +import type { ToolConfig } from '@/types' +import { z } from 'zod' +import { getGithubAuthorizeUrl, getGithubStatus } from './api' + +const emptyParams = z.object({}).strict() + +const notConfiguredMessage = 'The coding agent is not configured on this deployment, so GitHub connect is unavailable.' +const disabledMessage = 'GitHub connect is currently disabled on the coding-agent broker.' +const failedConnectMessage = "Couldn't reach the coding-agent broker to start GitHub connect. Please try again shortly." +const failedStatusMessage = + "Couldn't reach the coding-agent broker to check your GitHub status. Please try again shortly." + +export const createConfigs = (httpClient: HttpClient): ToolConfig[] => [ + { + name: 'github_connect', + description: + "Start connecting the current user's GitHub account to the coding agent. Returns a URL the user must open to authorize. Use when the user wants to connect GitHub or when a coding-agent action needs GitHub access.", + verb: 'starting GitHub connect', + parameters: emptyParams, + execute: async () => { + const result = await getGithubAuthorizeUrl(httpClient) + if (!result.configured) { + return { connected: false, message: notConfiguredMessage } + } + if (result.status === 'disabled') { + return { connected: false, message: disabledMessage } + } + if (result.status === 'failed') { + return { connected: false, message: failedConnectMessage } + } + return { + url: result.url, + message: `Connect your GitHub: ${result.url}`, + } + }, + }, + { + name: 'github_status', + description: + "Check whether the current user's GitHub account is connected to the coding agent. Takes no arguments.", + verb: 'checking GitHub status', + parameters: emptyParams, + execute: async () => { + const result = await getGithubStatus(httpClient) + if (!result.configured) { + return { connected: false, message: notConfiguredMessage } + } + if (result.status === 'disabled') { + return { connected: false, message: disabledMessage } + } + if (result.status === 'failed') { + return { connected: false, message: failedStatusMessage } + } + return { + connected: result.connected, + message: result.connected + ? 'Your GitHub account is connected to the coding agent.' + : 'Your GitHub account is not connected yet. Use github_connect to get a link to connect it.', + } + }, + }, +] diff --git a/src/lib/tools.ts b/src/lib/tools.ts index 142e6a97c..7d32ab893 100644 --- a/src/lib/tools.ts +++ b/src/lib/tools.ts @@ -6,6 +6,7 @@ import type { HttpClient } from '@/contexts' import { getIntegrationStatus, getSettings } from '@/dal' import { getDb } from '@/db/database' import * as tasksTools from '@/extensions/tasks/tools' +import { createConfigs as createCodingAgentConfigs } from '@/integrations/coding-agent/tools' import { createConfigs as createGoogleConfigs } from '@/integrations/google/tools' import { createConfigs as createMicrosoftConfigs } from '@/integrations/microsoft/tools' import { createConfigs as createProConfigs } from '@/integrations/thunderbolt-pro/tools' @@ -29,6 +30,11 @@ export const getAvailableTools = async ( const baseTools: ToolConfig[] = experimentalFeatureTasks ? [...Object.values(tasksTools)] : [] + // Coding-agent GitHub connect/status. Always offered: the backend reports + // `configured: false` cleanly when the broker isn't wired, so the assistant can + // tell the user it's unavailable rather than the tool simply not existing. + baseTools.push(...createCodingAgentConfigs(httpClient)) + const shouldIncludeProTools = proEnabled && integrationsProIsEnabled if (shouldIncludeProTools) {