From 6034f86d442299ee623cf023a4313732dc3cc5a0 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Mon, 13 Jul 2026 11:36:08 -0700 Subject: [PATCH 01/16] Add Devin Outposts dashboard connection Keep the discovery credential on the dashboard server and inject only the scoped machine token into idempotently launched worker sandboxes. Sanitize tRPC inputs in server and browser telemetry so connection credentials do not reach logs. --- .../[teamSlug]/connections/devin/page.tsx | 24 ++ .../[teamSlug]/connections/layout.tsx | 50 +++ .../dashboard/[teamSlug]/connections/page.tsx | 87 ++--- src/configs/layout.ts | 13 + src/configs/sidebar.ts | 2 +- src/configs/urls.ts | 2 + .../modules/devin-outposts/client.server.ts | 192 ++++++++++ src/core/server/actions/client.ts | 7 +- src/core/server/actions/utils.ts | 62 ---- src/core/server/api/middlewares/telemetry.ts | 6 +- src/core/server/api/routers/connections.ts | 170 +++++++++ src/core/server/api/routers/index.ts | 2 + .../shared/observability/sanitize-input.ts | 51 +++ .../connections/devin-connection-form.tsx | 337 ++++++++++++++++++ src/trpc/client.tsx | 23 ++ tests/unit/devin-outposts-client.test.ts | 111 ++++++ tests/unit/telemetry-input.test.ts | 20 ++ 17 files changed, 1047 insertions(+), 112 deletions(-) create mode 100644 src/app/dashboard/[teamSlug]/connections/devin/page.tsx create mode 100644 src/app/dashboard/[teamSlug]/connections/layout.tsx create mode 100644 src/core/modules/devin-outposts/client.server.ts create mode 100644 src/core/server/api/routers/connections.ts create mode 100644 src/core/shared/observability/sanitize-input.ts create mode 100644 src/features/dashboard/connections/devin-connection-form.tsx create mode 100644 tests/unit/devin-outposts-client.test.ts create mode 100644 tests/unit/telemetry-input.test.ts diff --git a/src/app/dashboard/[teamSlug]/connections/devin/page.tsx b/src/app/dashboard/[teamSlug]/connections/devin/page.tsx new file mode 100644 index 000000000..8c1f7c59f --- /dev/null +++ b/src/app/dashboard/[teamSlug]/connections/devin/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from 'next' +import { DevinConnectionForm } from '@/features/dashboard/connections/devin-connection-form' +import { Page } from '@/features/dashboard/layouts/page' + +export const metadata: Metadata = { + title: 'Devin connection - E2B', + robots: 'noindex, nofollow', +} + +interface DevinConnectionPageProps { + params: Promise<{ teamSlug: string }> +} + +export default async function DevinConnectionPage({ + params, +}: DevinConnectionPageProps) { + const { teamSlug } = await params + + return ( + + + + ) +} diff --git a/src/app/dashboard/[teamSlug]/connections/layout.tsx b/src/app/dashboard/[teamSlug]/connections/layout.tsx new file mode 100644 index 000000000..3ab1ca0a4 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/connections/layout.tsx @@ -0,0 +1,50 @@ +import { notFound, redirect } from 'next/navigation' +import { AUTH_URLS } from '@/configs/urls' +import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server' +import { getAuthContext } from '@/core/server/auth' +import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' + +type ConnectionsLayoutProps = { + children: React.ReactNode + params: Promise<{ teamSlug: string }> +} + +export default async function ConnectionsLayout({ + children, + params, +}: ConnectionsLayoutProps) { + const [{ teamSlug }, authContext] = await Promise.all([ + params, + getAuthContext(), + ]) + + if (!authContext) { + redirect(AUTH_URLS.SIGN_IN) + } + + const teamId = await getTeamIdFromSlug(teamSlug, authContext.accessToken) + + if (!teamId.ok || !teamId.data) { + notFound() + } + + const connectionsEnabled = await featureFlags.isEnabled( + 'connectionsEnabled', + { + user: { + id: authContext.user.id, + email: authContext.user.email ?? undefined, + }, + team: { + id: teamId.data, + slug: teamSlug, + }, + } + ) + + if (!connectionsEnabled) { + notFound() + } + + return children +} diff --git a/src/app/dashboard/[teamSlug]/connections/page.tsx b/src/app/dashboard/[teamSlug]/connections/page.tsx index 7a221da69..08d75ad2e 100644 --- a/src/app/dashboard/[teamSlug]/connections/page.tsx +++ b/src/app/dashboard/[teamSlug]/connections/page.tsx @@ -1,55 +1,58 @@ import type { Metadata } from 'next' -import { notFound, redirect } from 'next/navigation' -import { AUTH_URLS } from '@/configs/urls' -import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server' -import { getAuthContext } from '@/core/server/auth' -import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' +import Link from 'next/link' +import { PROTECTED_URLS } from '@/configs/urls' +import { Page } from '@/features/dashboard/layouts/page' +import { ArrowRightIcon, TerminalIcon } from '@/ui/primitives/icons' export const metadata: Metadata = { title: 'Connections - E2B', } -type ConnectionsPageProps = { - params: Promise<{ - teamSlug: string - }> +interface ConnectionsPageProps { + params: Promise<{ teamSlug: string }> } export default async function ConnectionsPage({ params, }: ConnectionsPageProps) { - const [{ teamSlug }, authContext] = await Promise.all([ - params, - getAuthContext(), - ]) - - if (!authContext) { - redirect(AUTH_URLS.SIGN_IN) - } - - const teamId = await getTeamIdFromSlug(teamSlug, authContext.accessToken) - - if (!teamId.ok || !teamId.data) { - notFound() - } - - const connectionsEnabled = await featureFlags.isEnabled( - 'connectionsEnabled', - { - user: { - id: authContext.user.id, - email: authContext.user.email ?? undefined, - }, - team: { - id: teamId.data, - slug: teamSlug, - }, - } + const { teamSlug } = await params + + return ( + +
+
+

Connections

+

+ Connect services that run workloads in your E2B team. +

+
+ +
+ +
+ +
+
+
+

Devin

+ + Outposts + +
+

+ Run Devin sessions on workers isolated in E2B sandboxes. +

+
+ + Configure + + + +
+
+
) - - if (!connectionsEnabled) { - notFound() - } - - return null } diff --git a/src/configs/layout.ts b/src/configs/layout.ts index b82f146d2..10b04e873 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -120,6 +120,19 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< webhookDetailLayoutConfig(pathname), '/dashboard/*/webhooks/*/deliveries': (pathname) => webhookDetailLayoutConfig(pathname), + '/dashboard/*/connections/devin': (pathname) => { + const teamSlug = pathname.split('/')[2] ?? '' + return { + title: [ + { + label: 'Connections', + href: PROTECTED_URLS.CONNECTIONS(teamSlug), + }, + { label: 'Devin' }, + ], + type: 'default', + } + }, // team '/dashboard/*/general': () => ({ diff --git a/src/configs/sidebar.ts b/src/configs/sidebar.ts index 87f969cd6..2a3cf9d4d 100644 --- a/src/configs/sidebar.ts +++ b/src/configs/sidebar.ts @@ -60,7 +60,7 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ group: 'integration', href: (args) => PROTECTED_URLS.CONNECTIONS(args.teamSlug!), icon: IntegrationsIcon, - activeMatch: `/dashboard/*/connections`, + activeMatch: `/dashboard/*/connections/**`, featureFlag: 'connectionsEnabled', }, { diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 1cb1c9127..de7dcb8f5 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -29,6 +29,8 @@ export const PROTECTED_URLS = { AGENTS: (teamSlug: string) => `/dashboard/${teamSlug}/agents`, BYOC: (teamSlug: string) => `/dashboard/${teamSlug}/byoc`, CONNECTIONS: (teamSlug: string) => `/dashboard/${teamSlug}/connections`, + CONNECTION_DEVIN: (teamSlug: string) => + `/dashboard/${teamSlug}/connections/devin`, SANDBOXES: (teamSlug: string) => `/dashboard/${teamSlug}/sandboxes/monitoring`, diff --git a/src/core/modules/devin-outposts/client.server.ts b/src/core/modules/devin-outposts/client.server.ts new file mode 100644 index 000000000..4279a7ce1 --- /dev/null +++ b/src/core/modules/devin-outposts/client.server.ts @@ -0,0 +1,192 @@ +import 'server-only' + +const REQUEST_TIMEOUT_MS = 15_000 +const ALLOWED_API_HOST_SUFFIXES = ['.devin.ai', '.devinenterprise.com'] + +export type DevinOrganization = { + id: string + name: string +} + +export type DevinPool = { + id: string + name: string + platform: string +} + +export type DevinDiscovery = { + organizations: DevinOrganization[] + pools: DevinPool[] +} + +export class DevinConnectionError extends Error { + constructor( + message: string, + readonly kind: 'credentials' | 'provider' | 'response' | 'url' + ) { + super(message) + } +} + +export function normalizeDevinApiUrl(value: string) { + let url: URL + try { + url = new URL(value.trim()) + } catch { + throw new DevinConnectionError('Enter a valid Devin API URL', 'url') + } + + const hostname = url.hostname.toLowerCase() + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + (url.pathname !== '/' && url.pathname !== '') || + url.search || + url.hash || + !ALLOWED_API_HOST_SUFFIXES.some( + (suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix) + ) + ) { + throw new DevinConnectionError( + 'Use the HTTPS API origin supplied by Devin', + 'url' + ) + } + + return url.origin +} + +export async function discoverDevinAccount( + apiUrlInput: string, + apiKey: string +): Promise { + const apiUrl = normalizeDevinApiUrl(apiUrlInput) + const credentials = { apiKey, apiUrl } + const self = await devinRequest(credentials, '/v3/self') + const selfOrgId = stringField(self, 'org_id') + + const organizations = selfOrgId + ? [{ id: selfOrgId, name: selfOrgId }] + : organizationsFromPayload( + await devinRequest(credentials, '/v3/enterprise/organizations') + ) + if (organizations.length === 0) { + throw new DevinConnectionError( + 'This Devin credential cannot access an organization', + 'credentials' + ) + } + + const pools = poolsFromPayload( + await devinRequest(credentials, '/opbeta/outposts/pools') + ) + return { organizations, pools } +} + +export function organizationsFromPayload(payload: Record) { + return collectionItems(payload) + .map((item): DevinOrganization | undefined => { + const id = stringField(item, 'org_id') + if (!id) return undefined + return { id, name: stringField(item, 'name') || id } + }) + .filter((item): item is DevinOrganization => Boolean(item)) +} + +export function poolsFromPayload(payload: Record) { + return collectionItems(payload) + .map((item): DevinPool | undefined => { + const metadata = recordField(item, 'metadata') + const spec = recordField(item, 'spec') + const id = stringField(metadata, 'pool_id') + const name = stringField(spec, 'name') + if (!id || !name) return undefined + return { id, name, platform: stringField(spec, 'platform') } + }) + .filter((item): item is DevinPool => Boolean(item)) +} + +async function devinRequest( + credentials: { apiUrl: string; apiKey: string }, + path: string +) { + let response: Response + try { + response = await fetch(`${credentials.apiUrl}${path}`, { + headers: { Authorization: `Bearer ${credentials.apiKey}` }, + cache: 'no-store', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + } catch { + throw new DevinConnectionError('Could not reach the Devin API', 'provider') + } + + if (response.status === 401 || response.status === 403) { + throw new DevinConnectionError( + 'Devin rejected this API credential', + 'credentials' + ) + } + if (!response.ok) { + throw new DevinConnectionError( + 'Devin account discovery is unavailable', + 'provider' + ) + } + + const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' + if (!contentType.includes('application/json')) { + throw new DevinConnectionError( + 'Devin returned an unexpected response', + 'response' + ) + } + + let payload: unknown + try { + payload = await response.json() + } catch { + throw new DevinConnectionError( + 'Devin returned an unexpected response', + 'response' + ) + } + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new DevinConnectionError( + 'Devin returned an unexpected response', + 'response' + ) + } + return payload as Record +} + +function records(value: unknown) { + if (!Array.isArray(value)) return [] + return value.filter( + (item): item is Record => + Boolean(item) && typeof item === 'object' && !Array.isArray(item) + ) +} + +function collectionItems(payload: Record) { + if (!Array.isArray(payload.items)) { + throw new DevinConnectionError( + 'Devin returned an unexpected response', + 'response' + ) + } + return records(payload.items) +} + +function recordField(record: Record, key: string) { + const value = record[key] + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function stringField(record: Record, key: string) { + return typeof record[key] === 'string' ? record[key] : '' +} diff --git a/src/core/server/actions/client.ts b/src/core/server/actions/client.ts index 119772c84..1acdefaa3 100644 --- a/src/core/server/actions/client.ts +++ b/src/core/server/actions/client.ts @@ -19,15 +19,12 @@ import { } from '@/core/shared/clients/logger/request-observability' import { getTracer } from '@/core/shared/clients/tracer' import { UnauthenticatedError, UnknownError } from '@/core/shared/errors' +import { sanitizeClientInput } from '@/core/shared/observability/sanitize-input' import type { RequestScope, TeamRequestScope, } from '@/core/shared/repository-scope' -import { - ActionError, - flattenClientInputValue, - sanitizeClientInput, -} from './utils' +import { ActionError, flattenClientInputValue } from './utils' type ActionSession = { access_token: string diff --git a/src/core/server/actions/utils.ts b/src/core/server/actions/utils.ts index 0fd70da5d..a5cb0df5a 100644 --- a/src/core/server/actions/utils.ts +++ b/src/core/server/actions/utils.ts @@ -44,65 +44,3 @@ export const flattenClientInputValue = ( return undefined } - -const SAFE_INPUT_KEYS = new Set([ - 'teamSlug', - 'teamId', - 'templateId', - 'sandboxId', - 'userId', - 'webhookId', - 'organizationId', - 'page', - 'pageSize', - 'limit', - 'offset', - 'sortBy', - 'sortOrder', - 'mode', - 'kind', - 'type', -]) - -function describeValue(value: unknown): string { - if (value === null) return 'null' - if (value === undefined) return 'undefined' - if (Array.isArray(value)) return `array(${value.length})` - const t = typeof value - if (t === 'string') return `string(${(value as string).length})` - if (t === 'object') return 'object' - return t -} - -/** - * Sanitize action `clientInput` for safe logging (e.g. telemetry summaries). - * - * - Allowlisted scalar keys are inlined as-is. - * - Everything else is replaced with `_: ` so shape and - * length are visible without leaking values. - * - * Allowlist-based sanitization is the inverse of the old blocklist-via-pino- - * redaction approach: new sensitive fields are safe by default and only become - * loggable when explicitly added to {@link SAFE_INPUT_KEYS}. - */ -export function sanitizeClientInput(input: unknown): Record { - if (!input || typeof input !== 'object' || Array.isArray(input)) { - return { _shape: describeValue(input) } - } - - const out: Record = {} - for (const [key, value] of Object.entries(input as Record)) { - const valueType = typeof value - if ( - SAFE_INPUT_KEYS.has(key) && - (valueType === 'string' || - valueType === 'number' || - valueType === 'boolean') - ) { - out[key] = value - } else { - out[`_${key}`] = describeValue(value) - } - } - return out -} diff --git a/src/core/server/api/middlewares/telemetry.ts b/src/core/server/api/middlewares/telemetry.ts index 68f33bdc8..1a6db3e8f 100644 --- a/src/core/server/api/middlewares/telemetry.ts +++ b/src/core/server/api/middlewares/telemetry.ts @@ -21,6 +21,7 @@ import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { withRequestObservabilityContext } from '@/core/shared/clients/logger/request-observability' import { getMeter } from '@/core/shared/clients/meter' import { getTracer } from '@/core/shared/clients/tracer' +import { sanitizeClientInput } from '@/core/shared/observability/sanitize-input' /** * Telemetry State @@ -166,6 +167,7 @@ export const endTelemetryMiddleware = t.middleware( // call next() first - execution resumes here after everything downstream completes const result = await next() const rawInput = await getRawInput() + const sanitizedInput = sanitizeClientInput(rawInput) const telemetry = 'telemetry' in ctx ? (ctx.telemetry as TelemetryState) : undefined @@ -241,7 +243,7 @@ export const endTelemetryMiddleware = t.middleware( 'trpc.router.name': routerName, 'trpc.procedure.type': procedureType, 'trpc.procedure.name': procedureName, - 'trpc.procedure.input': rawInput, + 'trpc.procedure.input': sanitizedInput, 'trpc.procedure.duration_ms': durationMs, error: serializeErrorForLog(observedError), @@ -287,7 +289,7 @@ export const endTelemetryMiddleware = t.middleware( 'trpc.router.name': routerName, 'trpc.procedure.type': procedureType, 'trpc.procedure.name': procedureName, - 'trpc.procedure.input': rawInput, + 'trpc.procedure.input': sanitizedInput, 'trpc.procedure.duration_ms': durationMs, }, `[tRPC] ${routerName}.${procedureName}` diff --git a/src/core/server/api/routers/connections.ts b/src/core/server/api/routers/connections.ts new file mode 100644 index 000000000..be8efa348 --- /dev/null +++ b/src/core/server/api/routers/connections.ts @@ -0,0 +1,170 @@ +import { TRPCError } from '@trpc/server' +import { Sandbox } from 'e2b' +import { z } from 'zod' +import { authHeaders } from '@/configs/api' +import { + DevinConnectionError, + discoverDevinAccount, + normalizeDevinApiUrl, +} from '@/core/modules/devin-outposts/client.server' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { protectedTeamProcedure } from '@/core/server/trpc/procedures' +import { l } from '@/core/shared/clients/logger/logger' +import { TERMINAL_SANDBOX_TIMEOUT_MS } from '@/features/dashboard/terminal/constants' + +const DEFAULT_DEVIN_TEMPLATE = 'devin-outposts' + +export const connectionsRouter = createTRPCRouter({ + discoverDevin: protectedTeamProcedure + .input( + z.object({ + apiKey: z.string().trim().min(1).max(4096), + apiUrl: z.string().trim().min(1).max(512), + }) + ) + .mutation(async ({ input }) => { + try { + return await discoverDevinAccount(input.apiUrl, input.apiKey) + } catch (error) { + if (error instanceof DevinConnectionError) { + throw new TRPCError({ + code: + error.kind === 'credentials' + ? 'UNAUTHORIZED' + : error.kind === 'url' + ? 'BAD_REQUEST' + : 'BAD_GATEWAY', + message: error.message, + }) + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Could not check the Devin account', + }) + } + }), + + launchDevinWorker: protectedTeamProcedure + .input( + z.object({ + apiUrl: z.string().trim().min(1).max(512), + operationId: z.string().uuid(), + outpostsToken: z.string().trim().min(1).max(4096), + poolId: z.string().trim().min(1).max(256), + }) + ) + .mutation(async ({ ctx, input }) => { + let apiUrl: string + try { + apiUrl = normalizeDevinApiUrl(input.apiUrl) + } catch (error) { + if (error instanceof DevinConnectionError) { + throw new TRPCError({ code: 'BAD_REQUEST', message: error.message }) + } + throw error + } + const acceptorId = `e2b-dashboard-${input.operationId.replaceAll('-', '').slice(0, 16)}` + const stateDir = `/home/user/.devin/worker/sessions/${acceptorId}` + const template = + process.env.DEVIN_OUTPOSTS_TEMPLATE || DEFAULT_DEVIN_TEMPLATE + const connectionOpts = { + apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + headers: authHeaders(ctx.session.access_token, ctx.teamId), + } + let sandbox: Sandbox | undefined + + try { + const existingLaunches = Sandbox.list({ + ...connectionOpts, + limit: 1, + query: { + metadata: { + devinLaunchOperationId: input.operationId, + source: 'dashboard-devin-outposts', + userId: ctx.session.user.id, + }, + }, + }) + const [existingLaunch] = await existingLaunches.nextItems() + if (existingLaunch) { + return { + acceptorId, + reused: true, + sandboxId: existingLaunch.sandboxId, + workerPid: null, + } + } + + sandbox = await Sandbox.create(template, { + ...connectionOpts, + timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, + lifecycle: { onTimeout: 'pause', autoResume: true }, + envs: { + DEVIN_API_URL: apiUrl, + DEVIN_OUTPOSTS_TOKEN: input.outpostsToken, + DEVIN_OUTPOST_POOL_ID: input.poolId, + DEVIN_REMOTE_STATE_DIR: stateDir, + DEVIN_WORKER_ACCEPTOR_ID: acceptorId, + }, + metadata: { + devinLaunchOperationId: input.operationId, + source: 'dashboard-devin-outposts', + userId: ctx.session.user.id, + }, + }) + + const result = await sandbox.commands.run( + [ + `mkdir -p ${shellQuote(stateDir)}`, + 'cd /mnt/repos', + `nohup devin worker start --api-url=${shellQuote(apiUrl)} --pool=${shellQuote(input.poolId)} --acceptor-id=${shellQuote(acceptorId)} /home/user/devin-worker.log 2>&1 &`, + 'worker_pid=$!', + 'sleep 2', + 'kill -0 "$worker_pid" 2>/dev/null', + 'printf "%s" "$worker_pid"', + ].join(' && '), + { timeoutMs: 30_000 } + ) + if (result.exitCode !== 0 || !/^\d+$/.test(result.stdout.trim())) { + throw new Error('worker_start_failed') + } + + return { + acceptorId, + reused: false, + sandboxId: sandbox.sandboxId, + workerPid: result.stdout.trim(), + } + } catch { + let orphanedSandboxId: string | undefined + if (sandbox) { + try { + await sandbox.kill() + } catch { + orphanedSandboxId = sandbox.sandboxId + l.error( + { + key: 'devin:worker_sandbox_cleanup_failed', + sandbox_id: sandbox.sandboxId, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + }, + '[Devin] Failed to clean up worker sandbox after launch failure' + ) + } + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: orphanedSandboxId + ? `Failed to start the Devin worker. Sandbox ${orphanedSandboxId} may require cleanup.` + : 'Failed to start the Devin Outposts worker', + }) + } + }), +}) + +function shellQuote(value: string) { + return `'${value.replaceAll("'", "'\\''")}'` +} diff --git a/src/core/server/api/routers/index.ts b/src/core/server/api/routers/index.ts index e38e546d2..2d169e281 100644 --- a/src/core/server/api/routers/index.ts +++ b/src/core/server/api/routers/index.ts @@ -1,6 +1,7 @@ import { createCallerFactory, createTRPCRouter } from '@/core/server/trpc/init' import { billingRouter } from './billing' import { buildsRouter } from './builds' +import { connectionsRouter } from './connections' import { sandboxRouter } from './sandbox' import { sandboxesRouter } from './sandboxes' import { supportRouter } from './support' @@ -15,6 +16,7 @@ export const trpcAppRouter = createTRPCRouter({ templates: templatesRouter, builds: buildsRouter, billing: billingRouter, + connections: connectionsRouter, support: supportRouter, teams: teamsRouter, user: userRouter, diff --git a/src/core/shared/observability/sanitize-input.ts b/src/core/shared/observability/sanitize-input.ts new file mode 100644 index 000000000..5d62d39c2 --- /dev/null +++ b/src/core/shared/observability/sanitize-input.ts @@ -0,0 +1,51 @@ +const SAFE_INPUT_KEYS = new Set([ + 'teamSlug', + 'teamId', + 'templateId', + 'sandboxId', + 'userId', + 'webhookId', + 'organizationId', + 'page', + 'pageSize', + 'limit', + 'offset', + 'sortBy', + 'sortOrder', + 'mode', + 'kind', + 'type', +]) + +function describeValue(value: unknown): string { + if (value === null) return 'null' + if (value === undefined) return 'undefined' + if (Array.isArray(value)) return `array(${value.length})` + const type = typeof value + if (type === 'string') return `string(${(value as string).length})` + if (type === 'object') return 'object' + return type +} + +/** Keep only explicitly allowlisted scalar values in observability output. */ +export function sanitizeClientInput(input: unknown): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return { _shape: describeValue(input) } + } + + const output: Record = {} + for (const [key, value] of Object.entries(input as Record)) { + const valueType = typeof value + if ( + SAFE_INPUT_KEYS.has(key) && + (valueType === 'string' || + valueType === 'number' || + valueType === 'boolean') + ) { + output[key] = value + } else { + output[`_${key}`] = describeValue(value) + } + } + return output +} diff --git a/src/features/dashboard/connections/devin-connection-form.tsx b/src/features/dashboard/connections/devin-connection-form.tsx new file mode 100644 index 000000000..7c76989d6 --- /dev/null +++ b/src/features/dashboard/connections/devin-connection-form.tsx @@ -0,0 +1,337 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { useMemo, useRef, useState } from 'react' +import { PROTECTED_URLS } from '@/configs/urls' +import { + defaultErrorToast, + defaultSuccessToast, + toast, +} from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { + CheckCircleIcon, + ExternalLinkIcon, + KeyIcon, + TerminalIcon, +} from '@/ui/primitives/icons' +import { Input } from '@/ui/primitives/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/ui/primitives/select' + +type Organization = { id: string; name: string } +type Pool = { id: string; name: string; platform: string } + +type DevinConnectionFormProps = { + teamSlug: string +} + +export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { + const router = useRouter() + const trpc = useTRPC() + const [apiUrl, setApiUrl] = useState('https://api.devin.ai') + const [apiKey, setApiKey] = useState('') + const [organizations, setOrganizations] = useState([]) + const [pools, setPools] = useState([]) + const [poolId, setPoolId] = useState('') + const [outpostsToken, setOutpostsToken] = useState('') + const [accountChecked, setAccountChecked] = useState(false) + const launchOperationId = useRef(crypto.randomUUID()) + + const discoverMutation = useMutation( + trpc.connections.discoverDevin.mutationOptions() + ) + + const launchMutation = useMutation( + trpc.connections.launchDevinWorker.mutationOptions() + ) + + const launchDisabledReason = useMemo(() => { + if (!accountChecked) return 'Check the Devin account first.' + if (!poolId) return 'Select an Outposts pool.' + if (!outpostsToken.trim()) return 'Enter a scoped Outposts machine token.' + return undefined + }, [accountChecked, outpostsToken, poolId]) + + function resetDiscovery() { + setAccountChecked(false) + setOrganizations([]) + setPools([]) + setPoolId('') + setOutpostsToken('') + } + + function handleApiUrlChange(value: string) { + setApiUrl(value) + resetDiscovery() + } + + async function checkAccount(event: React.FormEvent) { + event.preventDefault() + if (!apiKey.trim() || discoverMutation.isPending) return + const submittedApiKey = apiKey + setApiKey('') + try { + const data = await discoverMutation.mutateAsync({ + apiKey: submittedApiKey, + apiUrl, + teamSlug, + }) + setOrganizations(data.organizations) + setPools(data.pools) + setPoolId(data.pools.length === 1 ? data.pools[0]?.id || '' : '') + setAccountChecked(true) + toast(defaultSuccessToast('Devin account checked.')) + } catch (error) { + resetDiscovery() + toast( + defaultErrorToast( + error instanceof Error && error.message + ? error.message + : 'Could not check Devin account.' + ) + ) + } finally { + discoverMutation.reset() + } + } + + async function startWorker(event: React.FormEvent) { + event.preventDefault() + if (launchDisabledReason || launchMutation.isPending) return + const submittedToken = outpostsToken + setOutpostsToken('') + try { + const data = await launchMutation.mutateAsync({ + apiUrl, + operationId: launchOperationId.current, + outpostsToken: submittedToken, + poolId, + teamSlug, + }) + launchOperationId.current = crypto.randomUUID() + toast( + defaultSuccessToast( + data.reused + ? 'Opened the existing Devin worker launch.' + : 'Devin worker process started.' + ) + ) + router.push(PROTECTED_URLS.SANDBOX_TERMINAL(teamSlug, data.sandboxId)) + } catch (error) { + toast( + defaultErrorToast( + error instanceof Error && error.message + ? error.message + : 'Failed to start the Devin Outposts worker.' + ) + ) + } finally { + launchMutation.reset() + } + } + + return ( +
+
+
+
+ +
+
+

Connection

+

Devin Outposts

+
+
+

+ Discover your Devin organizations and pools from the dashboard, then + start a worker using only a scoped machine token inside the sandbox. +

+
+ +
+
+ +
+

+ Check Devin account +

+

+ The API key is sent to the dashboard server for this lookup. It is + cleared when the check starts and is never sent to the sandbox. +

+
+
+ +
+ + { + setApiKey(event.target.value) + resetDiscovery() + }} + placeholder="cog_..." + /> + + +
+ + Advanced API settings + +
+ + handleApiUrlChange(event.target.value)} + placeholder="https://api.devin.ai" + /> + +
+
+ + +
+
+ +
+
+ +
+

+ Choose worker target +

+

+ Select the Outposts pool this worker should serve. +

+
+
+ +
+ {accountChecked ? ( +

+ Account access verified for{' '} + + {organizations + .map((organization) => organization.name) + .join(', ')} + + . +

+ ) : null} + +
+ + + +
+ + {accountChecked && pools.length === 0 ? ( +

+ This account has no Outposts pools. Create one in Devin, then + check the account again. +

+ ) : null} + + + setOutpostsToken(event.target.value)} + placeholder="Token with the outposts machine scope" + /> + + +

+ This scoped token is injected into the worker sandbox. Do not use a + broad enterprise administrator credential here. +

+ + +

+ {launchDisabledReason || + 'Ready to create an E2B sandbox and start the Devin worker.'} +

+
+
+
+ ) +} + +function Field({ + children, + id, + label, +}: { + children: React.ReactNode + id: string + label: string +}) { + return ( +
+ + {children} +
+ ) +} diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx index 9accffd8d..ee4aee309 100644 --- a/src/trpc/client.tsx +++ b/src/trpc/client.tsx @@ -9,6 +9,7 @@ import { createTRPCContext } from '@trpc/tanstack-react-query' import { useState } from 'react' import SuperJSON from 'superjson' import type { TRPCAppRouter } from '@/core/server/api/routers' +import { sanitizeClientInput } from '@/core/shared/observability/sanitize-input' import { createQueryClient } from './query-client' export const { TRPCProvider, useTRPC, useTRPCClient } = @@ -66,6 +67,10 @@ export function TRPCReactProvider( createTRPCClient({ links: [ loggerLink({ + console: { + error: (...args) => console.error(...sanitizeLoggerArgs(args)), + log: (...args) => console.log(...sanitizeLoggerArgs(args)), + }, enabled: (opts) => (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') || @@ -92,3 +97,21 @@ export function TRPCReactProvider( ) } + +function sanitizeLoggerArgs(args: unknown[]) { + return args.map((argument) => { + if ( + !argument || + typeof argument !== 'object' || + Array.isArray(argument) || + !('input' in argument) + ) { + return argument + } + + return { + ...argument, + input: sanitizeClientInput(argument.input), + } + }) +} diff --git a/tests/unit/devin-outposts-client.test.ts b/tests/unit/devin-outposts-client.test.ts new file mode 100644 index 000000000..5f848a91c --- /dev/null +++ b/tests/unit/devin-outposts-client.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DevinConnectionError, + discoverDevinAccount, + normalizeDevinApiUrl, + organizationsFromPayload, + poolsFromPayload, +} from '@/core/modules/devin-outposts/client.server' + +describe('Devin Outposts API boundary', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('accepts Devin API origins and strips the trailing slash', () => { + expect(normalizeDevinApiUrl('https://api.devin.ai/')).toBe( + 'https://api.devin.ai' + ) + expect(normalizeDevinApiUrl('https://api.beta.devinenterprise.com')).toBe( + 'https://api.beta.devinenterprise.com' + ) + }) + + it.each([ + 'http://api.devin.ai', + 'https://example.com', + 'https://api.devin.ai/admin', + 'https://user:password@api.devin.ai', + 'https://api.devin.ai:8443', + 'https://api.devin.ai?target=internal', + 'https://api.devin.ai#fragment', + 'https://api.devin.ai.example.com', + 'not a URL', + ])('rejects unsafe server-side API URL %s', (value) => { + expect(() => normalizeDevinApiUrl(value)).toThrow(DevinConnectionError) + }) + + it('parses organizations and ignores malformed records', () => { + expect( + organizationsFromPayload({ + items: [ + { org_id: 'org-1', name: 'Engineering' }, + { name: 'Missing ID' }, + null, + ], + }) + ).toEqual([{ id: 'org-1', name: 'Engineering' }]) + }) + + it('parses Outposts pools and ignores incomplete records', () => { + expect( + poolsFromPayload({ + items: [ + { + metadata: { pool_id: 'pool-1' }, + spec: { name: 'Linux workers', platform: 'linux' }, + }, + { metadata: { pool_id: 'missing-name' }, spec: {} }, + ], + }) + ).toEqual([{ id: 'pool-1', name: 'Linux workers', platform: 'linux' }]) + }) + + it('rejects malformed collection payloads', () => { + expect(() => organizationsFromPayload({})).toThrow( + 'Devin returned an unexpected response' + ) + expect(() => poolsFromPayload({ items: null })).toThrow( + 'Devin returned an unexpected response' + ) + }) + + it('classifies rejected credentials without continuing discovery', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { + headers: { 'content-type': 'application/json' }, + status: 401, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect( + discoverDevinAccount('https://api.devin.ai', 'test-key') + ).rejects.toMatchObject({ kind: 'credentials' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('rejects malformed pool responses after authenticated discovery', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ org_id: 'org-1' }, { status: 200 }) + ) + .mockResolvedValueOnce(Response.json({}, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + discoverDevinAccount('https://api.devin.ai', 'test-key') + ).rejects.toMatchObject({ kind: 'response' }) + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.devin.ai/v3/self', + expect.objectContaining({ + headers: { Authorization: 'Bearer test-key' }, + }) + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.devin.ai/opbeta/outposts/pools', + expect.any(Object) + ) + }) +}) diff --git a/tests/unit/telemetry-input.test.ts b/tests/unit/telemetry-input.test.ts new file mode 100644 index 000000000..3aaa09411 --- /dev/null +++ b/tests/unit/telemetry-input.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeClientInput } from '@/core/shared/observability/sanitize-input' + +describe('tRPC telemetry input', () => { + it('keeps identifiers and replaces credentials with type hints', () => { + expect( + sanitizeClientInput({ + apiKey: 'broad-secret', + outpostsToken: 'scoped-secret', + poolId: 'pool-secret-shaped', + teamSlug: 'example-team', + }) + ).toEqual({ + _apiKey: 'string(12)', + _outpostsToken: 'string(13)', + _poolId: 'string(18)', + teamSlug: 'example-team', + }) + }) +}) From 927f8a636b691b6c2bef6b2693a9c3c0ecd31f68 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Mon, 13 Jul 2026 13:17:57 -0700 Subject: [PATCH 02/16] Connect Devin Outposts with partner OAuth Persist scoped connection state inside the prepared worker sandbox and serialize callbacks so one-time Devin codes can recover without exposing credentials to browser state. Keep partner OAuth opt-in per deployment and retain the manual scoped-token fallback. --- .env.example | 5 + src/app/api/connections/devin/start/route.ts | 142 ++++++ src/app/callback/route.ts | 252 ++++++++++ .../[teamSlug]/connections/devin/page.tsx | 39 +- .../modules/devin-outposts/oauth.server.ts | 275 +++++++++++ .../modules/devin-outposts/worker.server.ts | 451 ++++++++++++++++++ src/core/server/api/routers/connections.ts | 120 +---- .../connections/devin-connection-form.tsx | 148 ++++-- src/lib/env.ts | 4 + tests/unit/devin-outposts-oauth.test.ts | 183 +++++++ tests/unit/devin-outposts-routes.test.ts | 434 +++++++++++++++++ tests/unit/devin-outposts-worker.test.ts | 264 ++++++++++ 12 files changed, 2163 insertions(+), 154 deletions(-) create mode 100644 src/app/api/connections/devin/start/route.ts create mode 100644 src/app/callback/route.ts create mode 100644 src/core/modules/devin-outposts/oauth.server.ts create mode 100644 src/core/modules/devin-outposts/worker.server.ts create mode 100644 tests/unit/devin-outposts-oauth.test.ts create mode 100644 tests/unit/devin-outposts-routes.test.ts create mode 100644 tests/unit/devin-outposts-worker.test.ts diff --git a/.env.example b/.env.example index 021679ea7..729c8ca17 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ DASHBOARD_API_ADMIN_TOKEN=your_dashboard_api_admin_token ### JWE key for the encrypted e2b_session cookie (carries the Hydra OIDC tokens). ### Generate with `openssl rand -hex 32`. E2B_SESSION_SECRET=your_e2b_session_secret +# Optional Devin partner OAuth callback. Cognition must allowlist the exact URL. +# DEVIN_OUTPOSTS_CALLBACK_URL=http://localhost:8765/callback +# DEVIN_OUTPOSTS_CONNECT_URL=https://app.devin.ai/outposts/connect +# DEVIN_OUTPOSTS_TOKEN_URL=https://api.devin.ai/outposts/connection-token +# DEVIN_OUTPOSTS_TEMPLATE=devin-outposts-worker ### Ory Network configuration ORY_SDK_URL=https://your-project.projects.oryapis.com diff --git a/src/app/api/connections/devin/start/route.ts b/src/app/api/connections/devin/start/route.ts new file mode 100644 index 000000000..f63feace0 --- /dev/null +++ b/src/app/api/connections/devin/start/route.ts @@ -0,0 +1,142 @@ +import 'server-only' + +import { randomUUID } from 'node:crypto' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { AUTH_URLS, getPublicAppOrigin, PROTECTED_URLS } from '@/configs/urls' +import { + createDevinOAuthAttempt, + getDevinOAuthConnectUrl, + getDevinOAuthCookieName, + getDevinOAuthCookieOptions, + isDevinOAuthConfigured, + readDevinOAuthAttempt, +} from '@/core/modules/devin-outposts/oauth.server' +import { + cleanupPreparedDevinWorker, + isPreparedDevinWorkerAvailable, + prepareDevinWorkerSandbox, +} from '@/core/modules/devin-outposts/worker.server' +import { getAuthContext } from '@/core/server/auth' +import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' +import { TeamSlugSchema } from '@/core/shared/schemas/team' + +export async function POST(request: NextRequest) { + const teamSlug = request.nextUrl.searchParams.get('teamSlug') ?? '' + const publicOrigin = getPublicAppOrigin(request.nextUrl.origin) + const connectionPath = TeamSlugSchema.safeParse(teamSlug).success + ? PROTECTED_URLS.CONNECTION_DEVIN(teamSlug) + : PROTECTED_URLS.DASHBOARD + const authContext = await getAuthContext() + + if (!hasSameOrigin(request, publicOrigin)) { + return new NextResponse(null, { status: 403 }) + } + + if (!authContext) { + const signInUrl = new URL(AUTH_URLS.SIGN_IN, publicOrigin) + signInUrl.searchParams.set('returnTo', connectionPath) + return NextResponse.redirect(signInUrl) + } + + const teamIdResult = await getTeamIdFromSlug( + teamSlug, + authContext.accessToken + ) + if (!teamIdResult.ok) { + return redirectToConnection(publicOrigin, connectionPath, 'dashboard') + } + if (!teamIdResult.data) { + return redirectToConnection(publicOrigin, connectionPath, 'access') + } + if (!isDevinOAuthConfigured(publicOrigin)) { + return redirectToConnection(publicOrigin, connectionPath, 'config') + } + + const activeAttempt = readDevinOAuthAttempt( + request.cookies.get(getDevinOAuthCookieName())?.value + ) + if (activeAttempt) { + const isSameAttempt = + activeAttempt.returnOrigin === publicOrigin && + activeAttempt.teamId === teamIdResult.data && + activeAttempt.teamSlug === teamSlug && + activeAttempt.userId === authContext.user.id + if (!isSameAttempt) { + const activePath = PROTECTED_URLS.CONNECTION_DEVIN(activeAttempt.teamSlug) + return redirectToConnection(publicOrigin, activePath, 'in_progress') + } + + const available = await isPreparedDevinWorkerAvailable({ + accessToken: authContext.accessToken, + operationId: activeAttempt.operationId, + sandboxId: activeAttempt.sandboxId, + teamId: activeAttempt.teamId, + userId: activeAttempt.userId, + }) + if (available) { + const response = NextResponse.redirect( + getDevinOAuthConnectUrl(activeAttempt), + 303 + ) + response.headers.set('Cache-Control', 'no-store') + return response + } + } + + const operationId = randomUUID() + let sandboxId: string + try { + const prepared = await prepareDevinWorkerSandbox({ + accessToken: authContext.accessToken, + operationId, + teamId: teamIdResult.data, + userId: authContext.user.id, + }) + sandboxId = prepared.sandboxId + } catch { + return redirectToConnection(publicOrigin, connectionPath, 'launch') + } + + try { + const { attemptCookie, connectUrl } = createDevinOAuthAttempt({ + operationId, + returnOrigin: publicOrigin, + sandboxId, + teamId: teamIdResult.data, + teamSlug, + userId: authContext.user.id, + }) + const response = NextResponse.redirect(connectUrl, 303) + response.cookies.set( + getDevinOAuthCookieName(), + attemptCookie, + getDevinOAuthCookieOptions() + ) + response.headers.set('Cache-Control', 'no-store') + return response + } catch { + await cleanupPreparedDevinWorker({ + accessToken: authContext.accessToken, + sandboxId, + teamId: teamIdResult.data, + }).catch(() => undefined) + return redirectToConnection(publicOrigin, connectionPath, 'config') + } +} + +function redirectToConnection(origin: string, path: string, status: string) { + const url = new URL(path, origin) + url.searchParams.set('devinOAuth', status) + return NextResponse.redirect(url, 303) +} + +function hasSameOrigin(request: NextRequest, publicOrigin: string) { + const origin = request.headers.get('origin') + if (!origin) return false + try { + return new URL(origin).origin === new URL(publicOrigin).origin + } catch { + return false + } +} diff --git a/src/app/callback/route.ts b/src/app/callback/route.ts new file mode 100644 index 000000000..eac4f0961 --- /dev/null +++ b/src/app/callback/route.ts @@ -0,0 +1,252 @@ +import 'server-only' + +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { BASE_URL, getPublicAppOrigin, PROTECTED_URLS } from '@/configs/urls' +import { + DevinOAuthError, + exchangeDevinConnectionCode, + getDevinOAuthCookieName, + getDevinOAuthCookieOptions, + readDevinOAuthAttempt, +} from '@/core/modules/devin-outposts/oauth.server' +import { + claimPreparedDevinWorker, + cleanupPreparedDevinWorker, + DevinWorkerLaunchError, + hasPersistedDevinConnection, + persistPreparedDevinConnection, + startPersistedDevinWorker, +} from '@/core/modules/devin-outposts/worker.server' +import { getAuthContext } from '@/core/server/auth' +import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' +import { l } from '@/core/shared/clients/logger/logger' + +export const maxDuration = 60 + +export async function GET(request: NextRequest) { + const fallbackOrigin = fallbackCallbackOrigin(request) + const attempt = readDevinOAuthAttempt( + request.cookies.get(getDevinOAuthCookieName())?.value + ) + const fallbackPath = attempt + ? PROTECTED_URLS.CONNECTION_DEVIN(attempt.teamSlug) + : PROTECTED_URLS.DASHBOARD + + if (!attempt) { + return finish( + connectionRedirect(fallbackOrigin, fallbackPath, 'invalid_state') + ) + } + + const connectionPath = PROTECTED_URLS.CONNECTION_DEVIN(attempt.teamSlug) + const authContext = await getAuthContext() + if (!authContext || authContext.user.id !== attempt.userId) { + return finish( + connectionRedirect(attempt.returnOrigin, connectionPath, 'session') + ) + } + + const teamIdResult = await getTeamIdFromSlug( + attempt.teamSlug, + authContext.accessToken + ) + if (!teamIdResult.ok) { + await cleanupAttemptSandbox(authContext.accessToken, attempt) + return finish( + connectionRedirect(attempt.returnOrigin, connectionPath, 'dashboard') + ) + } + if (!teamIdResult.data) { + await cleanupAttemptSandbox(authContext.accessToken, attempt) + return finish( + connectionRedirect(attempt.returnOrigin, connectionPath, 'access') + ) + } + if (teamIdResult.data !== attempt.teamId) { + await cleanupAttemptSandbox(authContext.accessToken, attempt) + return finish( + connectionRedirect(attempt.returnOrigin, connectionPath, 'access') + ) + } + + const workerInput = { + accessToken: authContext.accessToken, + operationId: attempt.operationId, + sandboxId: attempt.sandboxId, + teamId: attempt.teamId, + userId: authContext.user.id, + } + let claim: 'busy' | 'claimed' | 'started' + try { + claim = await claimPreparedDevinWorker(workerInput) + } catch (error) { + l.warn( + { + key: 'devin:oauth_callback_claim_failed', + context: { + error_name: error instanceof Error ? error.name : 'unknown', + reason: + error instanceof Error && + /^sandbox_[a-z_]+(?:_\d{3})?$/.test(error.message) + ? error.message + : 'unknown', + }, + sandbox_id: attempt.sandboxId, + team_id: attempt.teamId, + user_id: authContext.user.id, + }, + '[Devin] Could not claim the prepared worker callback' + ) + return preserve( + connectionRedirect(attempt.returnOrigin, connectionPath, 'launch') + ) + } + if (claim === 'started') { + return finish( + terminalRedirect( + attempt.returnOrigin, + attempt.teamSlug, + attempt.sandboxId + ) + ) + } + if (claim === 'busy') { + return preserve( + connectionRedirect(attempt.returnOrigin, connectionPath, 'in_progress') + ) + } + + let credentialsPersisted = false + try { + credentialsPersisted = await hasPersistedDevinConnection(workerInput) + } catch (error) { + l.warn( + { + key: 'devin:oauth_persisted_state_check_failed', + context: { + error_name: error instanceof Error ? error.name : 'unknown', + }, + sandbox_id: attempt.sandboxId, + team_id: attempt.teamId, + user_id: authContext.user.id, + }, + '[Devin] Could not inspect persisted worker state' + ) + return preserve( + connectionRedirect(attempt.returnOrigin, connectionPath, 'launch') + ) + } + + const codes = request.nextUrl.searchParams.getAll('code') + const providerErrors = request.nextUrl.searchParams.getAll('error') + const code = codes.length === 1 ? codes[0] : undefined + if (!credentialsPersisted && (!code || code.length > 4096)) { + const explicitlyDenied = + providerErrors.length === 1 && providerErrors[0] === 'access_denied' + return preserve( + connectionRedirect( + attempt.returnOrigin, + connectionPath, + explicitlyDenied ? 'denied' : 'provider' + ) + ) + } + + let exchangeCompleted = false + try { + if (!credentialsPersisted) { + const token = await exchangeDevinConnectionCode(code as string, attempt) + exchangeCompleted = true + try { + await persistPreparedDevinConnection({ + ...workerInput, + apiUrl: token.apiUrl, + outpostsToken: token.accessToken, + poolId: token.poolId, + }) + credentialsPersisted = true + } catch { + credentialsPersisted = await hasPersistedDevinConnection(workerInput) + if (!credentialsPersisted) throw new DevinWorkerLaunchError() + } + } + const worker = await startPersistedDevinWorker(workerInput, { + cleanupOnFailure: false, + }) + return finish( + terminalRedirect(attempt.returnOrigin, attempt.teamSlug, worker.sandboxId) + ) + } catch (error) { + const status = + error instanceof DevinOAuthError && error.kind === 'invalid_grant' + ? 'expired' + : error instanceof DevinWorkerLaunchError + ? 'launch' + : 'provider' + l.warn( + { + key: 'devin:oauth_callback_failed', + context: { kind: status }, + team_id: teamIdResult.data, + user_id: authContext.user.id, + }, + '[Devin] OAuth connection did not complete' + ) + return credentialsPersisted || exchangeCompleted || status === 'expired' + ? preserve( + connectionRedirect(attempt.returnOrigin, connectionPath, status) + ) + : finish(connectionRedirect(attempt.returnOrigin, connectionPath, status)) + } +} + +function terminalRedirect(origin: string, teamSlug: string, sandboxId: string) { + return NextResponse.redirect( + new URL(PROTECTED_URLS.SANDBOX_TERMINAL(teamSlug, sandboxId), origin) + ) +} + +async function cleanupAttemptSandbox( + accessToken: string, + attempt: { sandboxId: string; teamId: string } +) { + await cleanupPreparedDevinWorker({ + accessToken, + sandboxId: attempt.sandboxId, + teamId: attempt.teamId, + }).catch(() => undefined) +} + +function connectionRedirect(origin: string, path: string, status: string) { + const url = new URL(path, origin) + url.searchParams.set('devinOAuth', status) + return NextResponse.redirect(url) +} + +function finish(response: NextResponse) { + response.cookies.set(getDevinOAuthCookieName(), '', { + ...getDevinOAuthCookieOptions(), + expires: new Date(0), + maxAge: 0, + }) + return secureResponse(response) +} + +function preserve(response: NextResponse) { + return secureResponse(response) +} + +function secureResponse(response: NextResponse) { + response.headers.set('Cache-Control', 'no-store') + response.headers.set('Referrer-Policy', 'no-referrer') + return response +} + +function fallbackCallbackOrigin(request: NextRequest) { + const hostname = request.nextUrl.hostname + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return new URL(BASE_URL).origin + } + return getPublicAppOrigin(request.nextUrl.origin) +} diff --git a/src/app/dashboard/[teamSlug]/connections/devin/page.tsx b/src/app/dashboard/[teamSlug]/connections/devin/page.tsx index 8c1f7c59f..dfe08ed37 100644 --- a/src/app/dashboard/[teamSlug]/connections/devin/page.tsx +++ b/src/app/dashboard/[teamSlug]/connections/devin/page.tsx @@ -1,4 +1,6 @@ import type { Metadata } from 'next' +import { BASE_URL } from '@/configs/urls' +import { isDevinOAuthConfigured } from '@/core/modules/devin-outposts/oauth.server' import { DevinConnectionForm } from '@/features/dashboard/connections/devin-connection-form' import { Page } from '@/features/dashboard/layouts/page' @@ -9,16 +11,51 @@ export const metadata: Metadata = { interface DevinConnectionPageProps { params: Promise<{ teamSlug: string }> + searchParams: Promise<{ devinOAuth?: string | string[] }> } export default async function DevinConnectionPage({ params, + searchParams, }: DevinConnectionPageProps) { const { teamSlug } = await params + const { devinOAuth } = await searchParams + const status = Array.isArray(devinOAuth) ? devinOAuth[0] : devinOAuth return ( - + ) } + +function oauthMessage(status: string) { + switch (status) { + case 'access': + return 'This dashboard session cannot access the selected team.' + case 'config': + return 'Devin partner OAuth is not configured for this dashboard deployment.' + case 'denied': + return 'The Devin connection was not authorized.' + case 'dashboard': + return 'The dashboard could not verify team access. Try again.' + case 'expired': + return 'The Devin authorization expired or was already used. Start the connection again.' + case 'invalid_state': + return 'This Devin connection attempt is missing or expired. Start it again.' + case 'in_progress': + return 'A Devin authorization is already in progress in this browser. Finish that attempt or wait for it to expire.' + case 'launch': + return 'The Devin worker sandbox could not be prepared or started.' + case 'provider': + return 'Devin could not complete the connection. Try again.' + case 'session': + return 'Your dashboard session changed during authorization. Sign in and start again.' + default: + return 'The Devin connection did not complete. Start it again.' + } +} diff --git a/src/core/modules/devin-outposts/oauth.server.ts b/src/core/modules/devin-outposts/oauth.server.ts new file mode 100644 index 000000000..dcd2affb0 --- /dev/null +++ b/src/core/modules/devin-outposts/oauth.server.ts @@ -0,0 +1,275 @@ +import 'server-only' + +import { + createHash, + createHmac, + randomBytes, + timingSafeEqual, +} from 'node:crypto' +import { z } from 'zod' +import { normalizeDevinApiUrl } from './client.server' + +const DEVIN_CONNECT_URL = 'https://app.devin.ai/outposts/connect' +const DEVIN_TOKEN_URL = 'https://api.devin.ai/outposts/connection-token' +const OAUTH_ATTEMPT_TTL_SECONDS = 30 * 60 +const REQUEST_TIMEOUT_MS = 15_000 + +const oauthAttemptSchema = z + .object({ + expiresAt: z.number().int().positive(), + nonce: z.string().min(32).max(128), + operationId: z.string().uuid(), + returnOrigin: z.string().url(), + sandboxId: z.string().min(1).max(256), + teamId: z.string().min(1).max(256), + teamSlug: z.string().min(1).max(256), + userId: z.string().min(1).max(256), + version: z.literal(1), + }) + .strict() + +const tokenResponseSchema = z + .object({ + access_token: z.string().min(1).max(8192), + api_base_url: z.string().min(1).max(512), + outpost_pool_id: z.string().min(1).max(256), + }) + .passthrough() + +export type DevinOAuthAttempt = z.infer + +export type DevinConnectionToken = { + accessToken: string + apiUrl: string + poolId: string +} + +export class DevinOAuthError extends Error { + constructor( + readonly kind: 'config' | 'invalid_grant' | 'provider' | 'response' + ) { + super(`Devin OAuth failed: ${kind}`) + } +} + +export function isDevinOAuthConfigured(returnOrigin: string) { + try { + validateCallbackHost(getCallbackUrl(), normalizeReturnOrigin(returnOrigin)) + getSecret() + return true + } catch { + return false + } +} + +export function createDevinOAuthAttempt(input: { + operationId: string + returnOrigin: string + sandboxId: string + teamId: string + teamSlug: string + userId: string +}) { + const attempt: DevinOAuthAttempt = { + expiresAt: Date.now() + OAUTH_ATTEMPT_TTL_SECONDS * 1000, + nonce: randomBytes(32).toString('base64url'), + operationId: input.operationId, + returnOrigin: normalizeReturnOrigin(input.returnOrigin), + sandboxId: input.sandboxId, + teamId: input.teamId, + teamSlug: input.teamSlug, + userId: input.userId, + version: 1, + } + return { + attemptCookie: signAttempt(attempt), + connectUrl: getDevinOAuthConnectUrl(attempt), + } +} + +export function getDevinOAuthConnectUrl(attempt: DevinOAuthAttempt) { + const verifier = deriveVerifier(attempt) + const callbackUrl = getCallbackUrl() + validateCallbackHost(callbackUrl, attempt.returnOrigin) + const connectUrl = new URL(DEVIN_CONNECT_URL) + connectUrl.searchParams.set('callback_url', callbackUrl) + connectUrl.searchParams.set('code_challenge', pkceChallenge(verifier)) + connectUrl.searchParams.set('platform', 'linux') + connectUrl.searchParams.set('pool_name', 'E2B worker') + + return connectUrl +} + +export function readDevinOAuthAttempt( + signedAttempt: string | undefined +): DevinOAuthAttempt | null { + if (!signedAttempt) return null + const [encoded, signature, ...rest] = signedAttempt.split('.') + if (!encoded || !signature || rest.length > 0) return null + + const expected = sign(encoded) + if (!safeEqual(signature, expected)) return null + + try { + const parsed = oauthAttemptSchema.safeParse( + JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) + ) + if (!parsed.success || parsed.data.expiresAt <= Date.now()) return null + return parsed.data + } catch { + return null + } +} + +export function getDevinOAuthCookieName() { + const instancePrefix = process.env.AUTH_COOKIE_PREFIX + ? `${process.env.AUTH_COOKIE_PREFIX}.` + : '' + return process.env.NODE_ENV === 'production' + ? `__Host-${instancePrefix}e2b-devin-oauth` + : `${instancePrefix}e2b-devin-oauth` +} + +export function getDevinOAuthCookieOptions() { + return { + httpOnly: true, + maxAge: OAUTH_ATTEMPT_TTL_SECONDS, + path: '/', + sameSite: 'lax' as const, + secure: process.env.NODE_ENV === 'production', + } +} + +export async function exchangeDevinConnectionCode( + code: string, + attempt: DevinOAuthAttempt +): Promise { + let response: Response + try { + response = await fetch(DEVIN_TOKEN_URL, { + body: new URLSearchParams({ + code, + code_verifier: deriveVerifier(attempt), + grant_type: 'authorization_code', + }), + cache: 'no-store', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + method: 'POST', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + } catch { + throw new DevinOAuthError('provider') + } + + const body = await readJsonRecord(response) + if (!response.ok) { + throw new DevinOAuthError( + response.status === 400 && body?.error === 'invalid_grant' + ? 'invalid_grant' + : 'provider' + ) + } + + const parsed = tokenResponseSchema.safeParse(body) + if (!parsed.success) throw new DevinOAuthError('response') + + return { + accessToken: parsed.data.access_token, + apiUrl: normalizeDevinApiUrl(parsed.data.api_base_url), + poolId: parsed.data.outpost_pool_id, + } +} + +function signAttempt(attempt: DevinOAuthAttempt) { + const encoded = Buffer.from(JSON.stringify(attempt)).toString('base64url') + return `${encoded}.${sign(encoded)}` +} + +function sign(value: string) { + return createHmac('sha256', getSecret()) + .update(`devin-oauth-state:${value}`) + .digest('base64url') +} + +function deriveVerifier(attempt: DevinOAuthAttempt) { + return createHmac('sha256', getSecret()) + .update( + `devin-oauth-pkce:${attempt.nonce}:${attempt.userId}:${attempt.teamId}:${attempt.operationId}:${attempt.expiresAt}` + ) + .digest('base64url') +} + +function pkceChallenge(verifier: string) { + return createHash('sha256').update(verifier).digest('base64url') +} + +function getSecret() { + const secret = process.env.AUTH_SECRET + if (!secret) throw new DevinOAuthError('config') + return secret +} + +function getCallbackUrl() { + const value = process.env.DEVIN_OUTPOSTS_CALLBACK_URL + if (!value) throw new DevinOAuthError('config') + + let url: URL + try { + url = new URL(value) + } catch { + throw new DevinOAuthError('config') + } + const localHttp = + url.protocol === 'http:' && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1') + if ( + (!localHttp && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash || + url.pathname !== '/callback' + ) { + throw new DevinOAuthError('config') + } + return url.toString() +} + +function normalizeReturnOrigin(value: string) { + const url = new URL(value) + const localHttp = + url.protocol === 'http:' && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1') + if ((!localHttp && url.protocol !== 'https:') || url.origin !== value) { + throw new DevinOAuthError('config') + } + return url.origin +} + +function validateCallbackHost(callbackUrl: string, returnOrigin: string) { + if (new URL(callbackUrl).hostname !== new URL(returnOrigin).hostname) { + throw new DevinOAuthError('config') + } +} + +async function readJsonRecord(response: Response) { + const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' + if (!contentType.includes('application/json')) return null + try { + const value: unknown = await response.json() + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null + } catch { + return null + } +} + +function safeEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ) +} diff --git a/src/core/modules/devin-outposts/worker.server.ts b/src/core/modules/devin-outposts/worker.server.ts new file mode 100644 index 000000000..390cd8002 --- /dev/null +++ b/src/core/modules/devin-outposts/worker.server.ts @@ -0,0 +1,451 @@ +import 'server-only' + +import { Sandbox } from 'e2b' +import { authHeaders } from '@/configs/api' +import type { components as InfraComponents } from '@/contracts/infra-api' +import { infra } from '@/core/shared/clients/api' +import { l } from '@/core/shared/clients/logger/logger' +import { normalizeDevinApiUrl } from './client.server' + +const ACTIVE_WORKER_TIMEOUT_MS = 24 * 60 * 60 * 1000 +const API_REQUEST_TIMEOUT_MS = 10_000 +const CALLBACK_LEASE_SECONDS = 2 * 60 +const DEFAULT_DEVIN_TEMPLATE = 'devin-outposts' +const PREPARED_SANDBOX_TIMEOUT_MS = 30 * 60 * 1000 +const WORKER_START_TIMEOUT_MS = 15_000 + +type WorkerIdentity = { + accessToken: string + operationId: string + teamId: string + userId: string +} + +export type LaunchDevinWorkerInput = WorkerIdentity & { + apiUrl: string + outpostsToken: string + poolId: string +} + +export type StartPreparedDevinWorkerInput = LaunchDevinWorkerInput & { + sandboxId: string +} + +type PreparedWorkerIdentity = WorkerIdentity & { sandboxId: string } + +export type PreparedDevinWorker = { + acceptorId: string + sandboxId: string + started: boolean +} + +export type LaunchDevinWorkerResult = { + acceptorId: string + reused: boolean + sandboxId: string + workerPid: string | null +} + +export class DevinWorkerLaunchError extends Error { + constructor(readonly orphanedSandboxId?: string) { + super('Failed to start the Devin Outposts worker') + } +} + +export async function launchDevinWorker( + input: LaunchDevinWorkerInput +): Promise { + const existingSandboxId = await findRunningWorkerSandbox(input) + const prepared = existingSandboxId + ? { sandboxId: existingSandboxId } + : await prepareDevinWorkerSandbox(input) + return startPreparedDevinWorker({ ...input, sandboxId: prepared.sandboxId }) +} + +export async function prepareDevinWorkerSandbox( + input: WorkerIdentity +): Promise { + const acceptorId = acceptorIdFor(input.operationId) + + try { + const sandbox = await createWorkerSandbox(input) + return { acceptorId, sandboxId: sandbox.sandboxID, started: false } + } catch (error) { + l.warn( + { + key: 'devin:worker_sandbox_prepare_failed', + context: { + error_code: safeErrorCode(error), + error_name: error instanceof Error ? error.name : 'unknown', + }, + team_id: input.teamId, + user_id: input.userId, + }, + '[Devin] Failed to prepare worker sandbox' + ) + throw new DevinWorkerLaunchError() + } +} + +export async function isPreparedDevinWorkerStarted( + input: PreparedWorkerIdentity +) { + const sandbox = await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) + return sandboxHasStartedWorker(sandbox, input.operationId) +} + +export async function isPreparedDevinWorkerAvailable( + input: PreparedWorkerIdentity +) { + try { + await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) + return true + } catch { + return false + } +} + +export async function claimPreparedDevinWorker( + input: PreparedWorkerIdentity +): Promise<'busy' | 'claimed' | 'started'> { + const sandbox = await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) + const stateDir = stateDirFor(input.operationId) + const lockDir = `${stateDir}/callback.lock` + const result = await sandbox.commands.run( + [ + `mkdir -p ${shellQuote(stateDir)}`, + `if worker_pid=$(cat ${shellQuote(workerMarkerPath(input.operationId))} 2>/dev/null) && kill -0 "$worker_pid" 2>/dev/null; then echo started; exit 0; fi`, + `if mkdir ${shellQuote(lockDir)} 2>/dev/null; then date +%s > ${shellQuote(`${lockDir}/claimed-at`)}; echo claimed; exit 0; fi`, + `claimed_at=$(cat ${shellQuote(`${lockDir}/claimed-at`)} 2>/dev/null || echo 0)`, + 'now=$(date +%s)', + `if [ $((now - claimed_at)) -gt ${CALLBACK_LEASE_SECONDS} ]; then rm -rf ${shellQuote(lockDir)} && mkdir ${shellQuote(lockDir)} && date +%s > ${shellQuote(`${lockDir}/claimed-at`)} && echo claimed; else echo busy; fi`, + ].join('\n'), + { requestTimeoutMs: API_REQUEST_TIMEOUT_MS } + ) + const state = result.stdout.trim() + if ( + result.exitCode !== 0 || + !['busy', 'claimed', 'started'].includes(state) + ) { + l.warn( + { + key: 'devin:worker_claim_invalid_result', + context: { + exit_code: result.exitCode, + state: ['busy', 'claimed', 'started'].includes(state) + ? state + : 'invalid', + }, + sandbox_id: input.sandboxId, + team_id: input.teamId, + user_id: input.userId, + }, + '[Devin] Worker callback claim returned an invalid result' + ) + throw new DevinWorkerLaunchError(input.sandboxId) + } + if (state === 'started') { + await extendWorkerSandbox(input, ACTIVE_WORKER_TIMEOUT_MS) + } + return state as 'busy' | 'claimed' | 'started' +} + +export async function hasPersistedDevinConnection( + input: PreparedWorkerIdentity +) { + const sandbox = await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) + const check = connectionFilePaths(input.operationId) + .map((path) => `test -s ${shellQuote(path)}`) + .join(' && ') + const result = await sandbox.commands.run( + `if ${check}; then printf present; else printf missing; fi`, + { requestTimeoutMs: API_REQUEST_TIMEOUT_MS } + ) + const state = result.stdout.trim() + if (result.exitCode !== 0 || !['missing', 'present'].includes(state)) { + throw new DevinWorkerLaunchError(input.sandboxId) + } + return state === 'present' +} + +export async function persistPreparedDevinConnection( + input: StartPreparedDevinWorkerInput +) { + const sandbox = await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) + const [apiUrlPath, poolIdPath, tokenPath] = connectionFilePaths( + input.operationId + ) + const result = await sandbox.commands.run( + [ + `mkdir -p ${shellQuote(stateDirFor(input.operationId))}`, + 'umask 077', + `printf %s "$DEVIN_API_URL" > ${shellQuote(apiUrlPath)}`, + `printf %s "$DEVIN_OUTPOST_POOL_ID" > ${shellQuote(poolIdPath)}`, + `printf %s "$DEVIN_OUTPOSTS_TOKEN" > ${shellQuote(tokenPath)}`, + ].join(' && '), + { + envs: { + DEVIN_API_URL: normalizeDevinApiUrl(input.apiUrl), + DEVIN_OUTPOSTS_TOKEN: input.outpostsToken, + DEVIN_OUTPOST_POOL_ID: input.poolId, + }, + requestTimeoutMs: API_REQUEST_TIMEOUT_MS, + } + ) + if (result.exitCode !== 0) throw new DevinWorkerLaunchError(input.sandboxId) +} + +export async function startPreparedDevinWorker( + input: StartPreparedDevinWorkerInput, + options: { cleanupOnFailure?: boolean } = {} +): Promise { + await persistPreparedDevinConnection(input) + return startPersistedDevinWorker(input, options) +} + +export async function startPersistedDevinWorker( + input: PreparedWorkerIdentity, + options: { cleanupOnFailure?: boolean } = {} +): Promise { + const acceptorId = acceptorIdFor(input.operationId) + const stateDir = stateDirFor(input.operationId) + let sandbox: Sandbox | undefined + + try { + sandbox = await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) + if (await sandboxHasStartedWorker(sandbox, input.operationId)) { + return { + acceptorId, + reused: true, + sandboxId: input.sandboxId, + workerPid: null, + } + } + + const result = await sandbox.commands.run( + [ + `mkdir -p ${shellQuote(stateDir)}`, + ...connectionFilePaths(input.operationId).map( + (path, index) => + `${['DEVIN_API_URL', 'DEVIN_OUTPOST_POOL_ID', 'DEVIN_OUTPOSTS_TOKEN'][index]}=$(cat ${shellQuote(path)})` + ), + 'export DEVIN_API_URL DEVIN_OUTPOST_POOL_ID DEVIN_OUTPOSTS_TOKEN', + `nohup devin worker start --api-url="$DEVIN_API_URL" --pool="$DEVIN_OUTPOST_POOL_ID" --acceptor-id=${shellQuote(acceptorId)} /home/user/devin-worker.log 2>&1 &`, + 'worker_pid=$!', + 'sleep 5', + 'kill -0 "$worker_pid" 2>/dev/null', + `printf "%s" "$worker_pid" > ${shellQuote(workerMarkerPath(input.operationId))}`, + 'printf "%s" "$worker_pid"', + ].join(' && '), + { + envs: { + DEVIN_REMOTE_STATE_DIR: stateDir, + DEVIN_WORKER_ACCEPTOR_ID: acceptorId, + }, + requestTimeoutMs: WORKER_START_TIMEOUT_MS, + } + ) + if (result.exitCode !== 0 || !/^\d+$/.test(result.stdout.trim())) { + throw new Error('worker_start_failed') + } + + await extendWorkerSandbox(input, ACTIVE_WORKER_TIMEOUT_MS) + + return { + acceptorId, + reused: false, + sandboxId: input.sandboxId, + workerPid: result.stdout.trim(), + } + } catch { + const cleaned = + options.cleanupOnFailure === false + ? true + : await cleanupPreparedDevinWorker(input).catch(() => false) + if (!cleaned) { + l.error( + { + key: 'devin:worker_sandbox_cleanup_failed', + sandbox_id: input.sandboxId, + team_id: input.teamId, + user_id: input.userId, + }, + '[Devin] Failed to clean up worker sandbox after launch failure' + ) + } + throw new DevinWorkerLaunchError(cleaned ? undefined : input.sandboxId) + } +} + +async function extendWorkerSandbox( + input: PreparedWorkerIdentity, + timeoutMs: number +) { + const result = await infra.POST('/sandboxes/{sandboxID}/connect', { + body: { timeout: millisecondsToSeconds(timeoutMs) }, + headers: authHeaders(input.accessToken, input.teamId), + params: { path: { sandboxID: input.sandboxId } }, + signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS), + }) + if (!result.response.ok) + throw new Error(`sandbox_connect_${result.response.status}`) +} + +async function sandboxHasStartedWorker(sandbox: Sandbox, operationId: string) { + const result = await sandbox.commands.run( + [ + `if worker_pid=$(cat ${shellQuote(workerMarkerPath(operationId))} 2>/dev/null) && test -n "$worker_pid" && kill -0 "$worker_pid" 2>/dev/null; then`, + ' printf running', + 'else', + ' printf stopped', + 'fi', + ].join('\n'), + { requestTimeoutMs: API_REQUEST_TIMEOUT_MS } + ) + const state = result.stdout.trim() + if (result.exitCode !== 0 || !['running', 'stopped'].includes(state)) { + throw new DevinWorkerLaunchError() + } + return state === 'running' +} + +export function cleanupPreparedDevinWorker( + input: Pick & { sandboxId: string } +) { + return infra + .DELETE('/sandboxes/{sandboxID}', { + headers: authHeaders(input.accessToken, input.teamId), + params: { path: { sandboxID: input.sandboxId } }, + signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS), + }) + .then(({ response }) => response.ok || response.status === 404) +} + +function connectionOptions(accessToken: string, teamId: string) { + return { + apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + apiHeaders: authHeaders(accessToken, teamId), + requestTimeoutMs: API_REQUEST_TIMEOUT_MS, + } +} + +async function findRunningWorkerSandbox(input: WorkerIdentity) { + const metadata = new URLSearchParams({ + devinLaunchOperationId: input.operationId, + userId: input.userId, + }).toString() + const result = await infra.GET('/sandboxes', { + headers: authHeaders(input.accessToken, input.teamId), + params: { query: { metadata } }, + signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS), + }) + if (!result.response.ok || !result.data) { + throw new DevinWorkerLaunchError() + } + return result.data[0]?.sandboxID +} + +async function createWorkerSandbox(input: WorkerIdentity) { + const result = await infra.POST('/sandboxes', { + body: { + autoPause: false, + autoResume: { enabled: false }, + metadata: launchMetadata(input.operationId, input.userId), + secure: true, + templateID: process.env.DEVIN_OUTPOSTS_TEMPLATE || DEFAULT_DEVIN_TEMPLATE, + timeout: millisecondsToSeconds(PREPARED_SANDBOX_TIMEOUT_MS), + }, + headers: authHeaders(input.accessToken, input.teamId), + signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS), + }) + if (!result.response.ok || !result.data) { + throw new Error(`sandbox_create_${result.response.status}`) + } + return result.data +} + +async function connectWorkerSandbox( + input: Pick & { + sandboxId: string + }, + timeoutMs: number +) { + const result = await infra.POST('/sandboxes/{sandboxID}/connect', { + body: { timeout: millisecondsToSeconds(timeoutMs) }, + headers: authHeaders(input.accessToken, input.teamId), + params: { path: { sandboxID: input.sandboxId } }, + signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS), + }) + if (!result.response.ok || !result.data) { + throw new Error(`sandbox_connect_${result.response.status}`) + } + return DevinWorkerSandbox.fromApi(result.data, input) +} + +class DevinWorkerSandbox extends Sandbox { + static fromApi( + sandbox: InfraComponents['schemas']['Sandbox'], + auth: Pick + ) { + if ( + !sandbox.envdAccessToken || + !sandbox.envdVersion || + !sandbox.sandboxID + ) { + throw new Error('sandbox_connect_invalid_response') + } + return new DevinWorkerSandbox({ + ...connectionOptions(auth.accessToken, auth.teamId), + envdAccessToken: sandbox.envdAccessToken, + envdVersion: sandbox.envdVersion, + sandboxDomain: sandbox.domain || undefined, + sandboxId: sandbox.sandboxID, + trafficAccessToken: sandbox.trafficAccessToken || undefined, + }) + } +} + +function millisecondsToSeconds(milliseconds: number) { + return Math.ceil(milliseconds / 1000) +} + +function acceptorIdFor(operationId: string) { + return `e2b-dashboard-${operationId.replaceAll('-', '').slice(0, 16)}` +} + +function stateDirFor(operationId: string) { + return `/home/user/.devin/worker/sessions/${acceptorIdFor(operationId)}` +} + +function workerMarkerPath(operationId: string) { + return `${stateDirFor(operationId)}/worker.pid` +} + +function connectionFilePaths(operationId: string): [string, string, string] { + const stateDir = stateDirFor(operationId) + return [ + `${stateDir}/api-url`, + `${stateDir}/pool-id`, + `${stateDir}/outposts-token`, + ] +} + +function launchMetadata(operationId: string, userId: string) { + return { + devinLaunchOperationId: operationId, + source: 'dashboard-devin-outposts', + userId, + } +} + +function shellQuote(value: string) { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function safeErrorCode(error: unknown) { + if (!error || typeof error !== 'object' || !('code' in error)) + return undefined + const code = error.code + return typeof code === 'string' || typeof code === 'number' ? code : undefined +} diff --git a/src/core/server/api/routers/connections.ts b/src/core/server/api/routers/connections.ts index be8efa348..149b8eef3 100644 --- a/src/core/server/api/routers/connections.ts +++ b/src/core/server/api/routers/connections.ts @@ -1,18 +1,16 @@ import { TRPCError } from '@trpc/server' -import { Sandbox } from 'e2b' import { z } from 'zod' -import { authHeaders } from '@/configs/api' import { DevinConnectionError, discoverDevinAccount, normalizeDevinApiUrl, } from '@/core/modules/devin-outposts/client.server' +import { + DevinWorkerLaunchError, + launchDevinWorker, +} from '@/core/modules/devin-outposts/worker.server' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' -import { l } from '@/core/shared/clients/logger/logger' -import { TERMINAL_SANDBOX_TIMEOUT_MS } from '@/features/dashboard/terminal/constants' - -const DEFAULT_DEVIN_TEMPLATE = 'devin-outposts' export const connectionsRouter = createTRPCRouter({ discoverDevin: protectedTeamProcedure @@ -54,107 +52,29 @@ export const connectionsRouter = createTRPCRouter({ }) ) .mutation(async ({ ctx, input }) => { - let apiUrl: string try { - apiUrl = normalizeDevinApiUrl(input.apiUrl) + normalizeDevinApiUrl(input.apiUrl) } catch (error) { if (error instanceof DevinConnectionError) { throw new TRPCError({ code: 'BAD_REQUEST', message: error.message }) } throw error } - const acceptorId = `e2b-dashboard-${input.operationId.replaceAll('-', '').slice(0, 16)}` - const stateDir = `/home/user/.devin/worker/sessions/${acceptorId}` - const template = - process.env.DEVIN_OUTPOSTS_TEMPLATE || DEFAULT_DEVIN_TEMPLATE - const connectionOpts = { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, - headers: authHeaders(ctx.session.access_token, ctx.teamId), - } - let sandbox: Sandbox | undefined - try { - const existingLaunches = Sandbox.list({ - ...connectionOpts, - limit: 1, - query: { - metadata: { - devinLaunchOperationId: input.operationId, - source: 'dashboard-devin-outposts', - userId: ctx.session.user.id, - }, - }, - }) - const [existingLaunch] = await existingLaunches.nextItems() - if (existingLaunch) { - return { - acceptorId, - reused: true, - sandboxId: existingLaunch.sandboxId, - workerPid: null, - } - } - - sandbox = await Sandbox.create(template, { - ...connectionOpts, - timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, - lifecycle: { onTimeout: 'pause', autoResume: true }, - envs: { - DEVIN_API_URL: apiUrl, - DEVIN_OUTPOSTS_TOKEN: input.outpostsToken, - DEVIN_OUTPOST_POOL_ID: input.poolId, - DEVIN_REMOTE_STATE_DIR: stateDir, - DEVIN_WORKER_ACCEPTOR_ID: acceptorId, - }, - metadata: { - devinLaunchOperationId: input.operationId, - source: 'dashboard-devin-outposts', - userId: ctx.session.user.id, - }, + return await launchDevinWorker({ + accessToken: ctx.session.access_token, + apiUrl: input.apiUrl, + operationId: input.operationId, + outpostsToken: input.outpostsToken, + poolId: input.poolId, + teamId: ctx.teamId, + userId: ctx.session.user.id, }) - - const result = await sandbox.commands.run( - [ - `mkdir -p ${shellQuote(stateDir)}`, - 'cd /mnt/repos', - `nohup devin worker start --api-url=${shellQuote(apiUrl)} --pool=${shellQuote(input.poolId)} --acceptor-id=${shellQuote(acceptorId)} /home/user/devin-worker.log 2>&1 &`, - 'worker_pid=$!', - 'sleep 2', - 'kill -0 "$worker_pid" 2>/dev/null', - 'printf "%s" "$worker_pid"', - ].join(' && '), - { timeoutMs: 30_000 } - ) - if (result.exitCode !== 0 || !/^\d+$/.test(result.stdout.trim())) { - throw new Error('worker_start_failed') - } - - return { - acceptorId, - reused: false, - sandboxId: sandbox.sandboxId, - workerPid: result.stdout.trim(), - } - } catch { - let orphanedSandboxId: string | undefined - if (sandbox) { - try { - await sandbox.kill() - } catch { - orphanedSandboxId = sandbox.sandboxId - l.error( - { - key: 'devin:worker_sandbox_cleanup_failed', - sandbox_id: sandbox.sandboxId, - team_id: ctx.teamId, - user_id: ctx.session.user.id, - }, - '[Devin] Failed to clean up worker sandbox after launch failure' - ) - } - } + } catch (error) { + const orphanedSandboxId = + error instanceof DevinWorkerLaunchError + ? error.orphanedSandboxId + : undefined throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: orphanedSandboxId @@ -164,7 +84,3 @@ export const connectionsRouter = createTRPCRouter({ } }), }) - -function shellQuote(value: string) { - return `'${value.replaceAll("'", "'\\''")}'` -} diff --git a/src/features/dashboard/connections/devin-connection-form.tsx b/src/features/dashboard/connections/devin-connection-form.tsx index 7c76989d6..b1245cbaa 100644 --- a/src/features/dashboard/connections/devin-connection-form.tsx +++ b/src/features/dashboard/connections/devin-connection-form.tsx @@ -1,15 +1,14 @@ 'use client' -import { useMutation } from '@tanstack/react-query' import { useRouter } from 'next/navigation' -import { useMemo, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { defaultErrorToast, defaultSuccessToast, toast, } from '@/lib/hooks/use-toast' -import { useTRPC } from '@/trpc/client' +import { useTRPCClient } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { CheckCircleIcon, @@ -30,35 +29,35 @@ type Organization = { id: string; name: string } type Pool = { id: string; name: string; platform: string } type DevinConnectionFormProps = { + oauthEnabled: boolean + oauthMessage?: string teamSlug: string } -export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { +export function DevinConnectionForm({ + oauthEnabled, + oauthMessage, + teamSlug, +}: DevinConnectionFormProps) { const router = useRouter() - const trpc = useTRPC() - const [apiUrl, setApiUrl] = useState('https://api.devin.ai') + const trpcClient = useTRPCClient() + const apiUrlRef = useRef(null) const [apiKey, setApiKey] = useState('') const [organizations, setOrganizations] = useState([]) const [pools, setPools] = useState([]) const [poolId, setPoolId] = useState('') const [outpostsToken, setOutpostsToken] = useState('') const [accountChecked, setAccountChecked] = useState(false) - const launchOperationId = useRef(crypto.randomUUID()) + const [discoverPending, setDiscoverPending] = useState(false) + const [launchPending, setLaunchPending] = useState(false) + const launchOperationId = useRef(null) - const discoverMutation = useMutation( - trpc.connections.discoverDevin.mutationOptions() - ) - - const launchMutation = useMutation( - trpc.connections.launchDevinWorker.mutationOptions() - ) - - const launchDisabledReason = useMemo(() => { + const launchDisabledReason = (() => { if (!accountChecked) return 'Check the Devin account first.' if (!poolId) return 'Select an Outposts pool.' if (!outpostsToken.trim()) return 'Enter a scoped Outposts machine token.' return undefined - }, [accountChecked, outpostsToken, poolId]) + })() function resetDiscovery() { setAccountChecked(false) @@ -68,20 +67,16 @@ export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { setOutpostsToken('') } - function handleApiUrlChange(value: string) { - setApiUrl(value) - resetDiscovery() - } - async function checkAccount(event: React.FormEvent) { event.preventDefault() - if (!apiKey.trim() || discoverMutation.isPending) return + if (!apiKey.trim() || discoverPending) return const submittedApiKey = apiKey setApiKey('') + setDiscoverPending(true) try { - const data = await discoverMutation.mutateAsync({ + const data = await trpcClient.connections.discoverDevin.mutate({ apiKey: submittedApiKey, - apiUrl, + apiUrl: apiUrlRef.current?.value || 'https://api.devin.ai', teamSlug, }) setOrganizations(data.organizations) @@ -98,25 +93,28 @@ export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { : 'Could not check Devin account.' ) ) - } finally { - discoverMutation.reset() } + setDiscoverPending(false) } async function startWorker(event: React.FormEvent) { event.preventDefault() - if (launchDisabledReason || launchMutation.isPending) return + if (launchDisabledReason || launchPending) return const submittedToken = outpostsToken setOutpostsToken('') + setLaunchPending(true) + if (!launchOperationId.current) { + launchOperationId.current = crypto.randomUUID() + } try { - const data = await launchMutation.mutateAsync({ - apiUrl, + const data = await trpcClient.connections.launchDevinWorker.mutate({ + apiUrl: apiUrlRef.current?.value || 'https://api.devin.ai', operationId: launchOperationId.current, outpostsToken: submittedToken, poolId, teamSlug, }) - launchOperationId.current = crypto.randomUUID() + launchOperationId.current = null toast( defaultSuccessToast( data.reused @@ -133,9 +131,8 @@ export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { : 'Failed to start the Devin Outposts worker.' ) ) - } finally { - launchMutation.reset() } + setLaunchPending(false) } return ( @@ -156,16 +153,67 @@ export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) {

+
+
+ +
+

Connect with Devin

+

+ Authorize E2B in Devin. Devin creates a dedicated pool and scoped + service user, then the dashboard stores the scoped worker + credential directly in the prepared sandbox. It never enters + browser state. +

+
+
+ + {oauthMessage ? ( +

+ {oauthMessage} +

+ ) : null} + +
+ {oauthEnabled ? ( +
+ +
+ ) : ( + + )} +
+ {!oauthEnabled ? ( +

+ Partner OAuth is not configured for this deployment. Use the manual + setup below. +

+ ) : null} +
+
-

- Check Devin account -

+

Manual setup

- The API key is sent to the dashboard server for this lookup. It is - cleared when the check starts and is never sent to the sandbox. + Use an existing service-user API key and scoped Outposts machine + token. The API key is sent to the dashboard server only for the + lookup, then cleared and never sent to the sandbox.

@@ -175,7 +223,7 @@ export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { { @@ -195,9 +243,10 @@ export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { handleApiUrlChange(event.target.value)} + disabled={discoverPending} + defaultValue="https://api.devin.ai" + onChange={resetDiscovery} + ref={apiUrlRef} placeholder="https://api.devin.ai" /> @@ -206,10 +255,8 @@ export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { -

{launchDisabledReason || 'Ready to create an E2B sandbox and start the Devin worker.'} -

+
diff --git a/src/lib/env.ts b/src/lib/env.ts index 23fa38870..df723f1f5 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -5,6 +5,10 @@ export const serverSchema = z.object({ BILLING_API_URL: z.url().optional(), PLAIN_API_KEY: z.string().min(1).optional(), + DEVIN_OUTPOSTS_CALLBACK_URL: z.url().optional(), + DEVIN_OUTPOSTS_CONNECT_URL: z.url().optional(), + DEVIN_OUTPOSTS_TOKEN_URL: z.url().optional(), + DEVIN_OUTPOSTS_TEMPLATE: z.string().min(1).optional(), POSTHOG_API_KEY: z.string().min(1).optional(), POSTHOG_PROJECT_ID: z.string().min(1).optional(), diff --git a/tests/unit/devin-outposts-oauth.test.ts b/tests/unit/devin-outposts-oauth.test.ts new file mode 100644 index 000000000..faf59f11f --- /dev/null +++ b/tests/unit/devin-outposts-oauth.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createDevinOAuthAttempt, + DevinOAuthError, + exchangeDevinConnectionCode, + getDevinOAuthCookieName, + getDevinOAuthCookieOptions, + isDevinOAuthConfigured, + readDevinOAuthAttempt, +} from '@/core/modules/devin-outposts/oauth.server' + +const ORIGINAL_AUTH_SECRET = process.env.AUTH_SECRET +const ORIGINAL_CALLBACK_URL = process.env.DEVIN_OUTPOSTS_CALLBACK_URL + +describe('Devin partner OAuth boundary', () => { + beforeEach(() => { + process.env.AUTH_SECRET = 'test-auth-secret-with-enough-entropy' + process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'http://localhost:8765/callback' + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.unstubAllEnvs() + restoreEnv('AUTH_SECRET', ORIGINAL_AUTH_SECRET) + restoreEnv('DEVIN_OUTPOSTS_CALLBACK_URL', ORIGINAL_CALLBACK_URL) + }) + + it('creates signed state and a matching S256 challenge', async () => { + const { attemptCookie, connectUrl } = createDevinOAuthAttempt({ + operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', + returnOrigin: 'http://localhost:3000', + sandboxId: 'sandbox-1', + teamId: 'team-1', + teamSlug: 'test-team', + userId: 'user-1', + }) + const attempt = readDevinOAuthAttempt(attemptCookie) + expect(attempt).toMatchObject({ + returnOrigin: 'http://localhost:3000', + sandboxId: 'sandbox-1', + teamId: 'team-1', + teamSlug: 'test-team', + userId: 'user-1', + }) + expect(connectUrl.origin).toBe('https://app.devin.ai') + expect(connectUrl.pathname).toBe('/outposts/connect') + expect(connectUrl.searchParams.get('callback_url')).toBe( + 'http://localhost:8765/callback' + ) + expect(connectUrl.searchParams.get('platform')).toBe('linux') + expect(connectUrl.searchParams.has('code_challenge_method')).toBe(false) + + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'scoped-token', + api_base_url: 'https://api.devin.ai', + outpost_pool_id: 'pool-1', + }) + ) + vi.stubGlobal('fetch', fetchMock) + if (!attempt) throw new Error('expected signed OAuth attempt') + await exchangeDevinConnectionCode('one-time-code', attempt) + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit + const body = request.body as URLSearchParams + const verifier = body.get('code_verifier') + if (!verifier) throw new Error('expected PKCE verifier') + const challenge = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(verifier) + ) + expect(Buffer.from(challenge).toString('base64url')).toBe( + connectUrl.searchParams.get('code_challenge') + ) + expect(attemptCookie).not.toContain(verifier) + }) + + it('rejects tampered and expired state', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-13T12:00:00Z')) + const { attemptCookie } = createDevinOAuthAttempt({ + operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', + returnOrigin: 'http://localhost:3000', + sandboxId: 'sandbox-1', + teamId: 'team-1', + teamSlug: 'test-team', + userId: 'user-1', + }) + + expect(readDevinOAuthAttempt(`${attemptCookie}x`)).toBeNull() + vi.advanceTimersByTime(30 * 60 * 1000 + 1) + expect(readDevinOAuthAttempt(attemptCookie)).toBeNull() + }) + + it('treats invalid_grant as terminal without exposing provider details', async () => { + const { attemptCookie } = createDevinOAuthAttempt({ + operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', + returnOrigin: 'http://localhost:3000', + sandboxId: 'sandbox-1', + teamId: 'team-1', + teamSlug: 'test-team', + userId: 'user-1', + }) + const attempt = readDevinOAuthAttempt(attemptCookie) + if (!attempt) throw new Error('expected signed OAuth attempt') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json( + { + error: 'invalid_grant', + error_description: 'sensitive provider detail', + }, + { status: 400 } + ) + ) + ) + + const error = await exchangeDevinConnectionCode( + 'expired-code', + attempt + ).catch((caught: unknown) => caught) + expect(error).toMatchObject({ kind: 'invalid_grant' }) + expect(error).not.toHaveProperty( + 'message', + expect.stringContaining('sensitive provider detail') + ) + }) + + it('rejects malformed success responses', async () => { + const { attemptCookie } = createDevinOAuthAttempt({ + operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', + returnOrigin: 'http://localhost:3000', + sandboxId: 'sandbox-1', + teamId: 'team-1', + teamSlug: 'test-team', + userId: 'user-1', + }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ access_token: 'token' })) + ) + + const attempt = readDevinOAuthAttempt(attemptCookie) + if (!attempt) throw new Error('expected signed OAuth attempt') + await expect( + exchangeDevinConnectionCode('one-time-code', attempt) + ).rejects.toMatchObject(new DevinOAuthError('response')) + }) + + it('fails closed for missing or unsafe callback configuration', () => { + process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'https://attacker.example/cb' + expect(isDevinOAuthConfigured('https://dashboard.example.com')).toBe(false) + + process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'http://localhost:8765/callback' + delete process.env.AUTH_SECRET + expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) + }) + + it('requires the callback to use the dashboard hostname', () => { + process.env.DEVIN_OUTPOSTS_CALLBACK_URL = + 'https://oauth.example.com/callback' + expect(isDevinOAuthConfigured('https://dashboard.example.com')).toBe(false) + }) + + it('uses an isolated secure host cookie in production', () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('AUTH_COOKIE_PREFIX', 'dashboard-a') + expect(getDevinOAuthCookieName()).toBe('__Host-dashboard-a.e2b-devin-oauth') + expect(getDevinOAuthCookieOptions()).toMatchObject({ + httpOnly: true, + path: '/', + sameSite: 'lax', + secure: true, + }) + }) +}) + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) delete process.env[name] + else process.env[name] = value +} diff --git a/tests/unit/devin-outposts-routes.test.ts b/tests/unit/devin-outposts-routes.test.ts new file mode 100644 index 000000000..54a977f21 --- /dev/null +++ b/tests/unit/devin-outposts-routes.test.ts @@ -0,0 +1,434 @@ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createAttempt: vi.fn(), + claimWorker: vi.fn(), + cleanupWorker: vi.fn(), + exchangeCode: vi.fn(), + getAuthContext: vi.fn(), + getConnectUrl: vi.fn(), + getTeamIdFromSlug: vi.fn(), + hasPersistedConnection: vi.fn(), + isWorkerAvailable: vi.fn(), + persistConnection: vi.fn(), + prepareWorker: vi.fn(), + readAttempt: vi.fn(), + startPersistedWorker: vi.fn(), +})) + +vi.mock('@/core/server/auth', () => ({ + getAuthContext: mocks.getAuthContext, +})) + +vi.mock('@/core/server/functions/team/get-team-id-from-slug', () => ({ + getTeamIdFromSlug: mocks.getTeamIdFromSlug, +})) + +vi.mock('@/core/modules/devin-outposts/oauth.server', () => { + class DevinOAuthError extends Error { + constructor(readonly kind: string) { + super(`oauth:${kind}`) + } + } + + return { + createDevinOAuthAttempt: mocks.createAttempt, + DevinOAuthError, + exchangeDevinConnectionCode: mocks.exchangeCode, + getDevinOAuthConnectUrl: mocks.getConnectUrl, + getDevinOAuthCookieName: () => 'e2b-devin-oauth', + getDevinOAuthCookieOptions: () => ({ + httpOnly: true, + maxAge: 1800, + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }), + isDevinOAuthConfigured: () => true, + readDevinOAuthAttempt: mocks.readAttempt, + } +}) + +vi.mock('@/core/modules/devin-outposts/worker.server', () => { + class DevinWorkerLaunchError extends Error { + constructor(readonly orphanedSandboxId?: string) { + super('launch failed') + } + } + + return { + cleanupPreparedDevinWorker: mocks.cleanupWorker, + claimPreparedDevinWorker: mocks.claimWorker, + DevinWorkerLaunchError, + hasPersistedDevinConnection: mocks.hasPersistedConnection, + isPreparedDevinWorkerAvailable: mocks.isWorkerAvailable, + persistPreparedDevinConnection: mocks.persistConnection, + prepareDevinWorkerSandbox: mocks.prepareWorker, + startPersistedDevinWorker: mocks.startPersistedWorker, + } +}) + +import { POST as start } from '@/app/api/connections/devin/start/route' +import { GET as callback } from '@/app/callback/route' +import { DevinOAuthError } from '@/core/modules/devin-outposts/oauth.server' + +const authContext = { + accessToken: 'dashboard-token', + user: { id: 'user-1' }, +} +const attempt = { + expiresAt: Date.now() + 60_000, + nonce: 'nonce', + operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', + returnOrigin: 'http://localhost:3000', + sandboxId: 'sandbox-1', + teamId: 'team-1', + teamSlug: 'test-team', + userId: 'user-1', + version: 1 as const, +} + +describe('Devin OAuth routes', () => { + beforeEach(() => { + vi.unstubAllEnvs() + mocks.createAttempt.mockReset() + mocks.claimWorker.mockReset() + mocks.cleanupWorker.mockReset() + mocks.exchangeCode.mockReset() + mocks.getAuthContext.mockReset() + mocks.getConnectUrl.mockReset() + mocks.getTeamIdFromSlug.mockReset() + mocks.hasPersistedConnection.mockReset() + mocks.isWorkerAvailable.mockReset() + mocks.persistConnection.mockReset() + mocks.prepareWorker.mockReset() + mocks.readAttempt.mockReset() + mocks.startPersistedWorker.mockReset() + mocks.getAuthContext.mockResolvedValue(authContext) + mocks.getTeamIdFromSlug.mockResolvedValue({ ok: true, data: 'team-1' }) + mocks.createAttempt.mockReturnValue({ + attemptCookie: 'signed-attempt', + connectUrl: new URL('https://app.devin.ai/outposts/connect?test=1'), + }) + mocks.getConnectUrl.mockReturnValue( + new URL('https://app.devin.ai/outposts/connect?resume=1') + ) + mocks.readAttempt.mockReturnValue(null) + mocks.claimWorker.mockResolvedValue('claimed') + mocks.cleanupWorker.mockResolvedValue(true) + mocks.hasPersistedConnection.mockResolvedValue(false) + mocks.isWorkerAvailable.mockResolvedValue(true) + mocks.prepareWorker.mockResolvedValue({ sandboxId: 'sandbox-1' }) + }) + + it('requires a Dashboard session before creating OAuth state', async () => { + mocks.getAuthContext.mockResolvedValue(null) + const response = await start( + startRequest( + 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team' + ) + ) + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/sign-in?returnTo=%2Fdashboard%2Ftest-team%2Fconnections%2Fdevin' + ) + expect(mocks.createAttempt).not.toHaveBeenCalled() + }) + + it('sets HttpOnly state and redirects an authorized user to Devin', async () => { + const response = await start( + startRequest( + 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team' + ) + ) + + expect(response.headers.get('location')).toBe( + 'https://app.devin.ai/outposts/connect?test=1' + ) + expect(response.status).toBe(303) + expect(response.headers.get('set-cookie')).toContain( + 'e2b-devin-oauth=signed-attempt' + ) + expect(response.headers.get('set-cookie')).toContain('HttpOnly') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mocks.prepareWorker).toHaveBeenCalledWith( + expect.objectContaining({ teamId: 'team-1', userId: 'user-1' }) + ) + expect(mocks.createAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxId: 'sandbox-1', + teamId: 'team-1', + teamSlug: 'test-team', + }) + ) + }) + + it('resumes an OAuth attempt without overwriting its state', async () => { + mocks.readAttempt.mockReturnValue(attempt) + const response = await start( + startRequest( + 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team' + ) + ) + + expect(response.headers.get('location')).toBe( + 'https://app.devin.ai/outposts/connect?resume=1' + ) + expect(mocks.getConnectUrl).toHaveBeenCalledWith(attempt) + expect(mocks.prepareWorker).not.toHaveBeenCalled() + expect(mocks.createAttempt).not.toHaveBeenCalled() + }) + + it('replaces an attempt whose prepared sandbox no longer exists', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.isWorkerAvailable.mockResolvedValue(false) + + const response = await start( + startRequest( + 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team' + ) + ) + + expect(response.headers.get('location')).toBe( + 'https://app.devin.ai/outposts/connect?test=1' + ) + expect(mocks.prepareWorker).toHaveBeenCalled() + expect(mocks.createAttempt).toHaveBeenCalled() + }) + + it('rejects cross-site OAuth starts before preparing a sandbox', async () => { + const response = await start( + startRequest( + 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team', + 'https://attacker.example' + ) + ) + + expect(response.status).toBe(403) + expect(mocks.prepareWorker).not.toHaveBeenCalled() + expect(mocks.createAttempt).not.toHaveBeenCalled() + }) + + it('rejects a callback without valid state and clears the cookie', async () => { + mocks.readAttempt.mockReturnValue(null) + const response = await callback( + new NextRequest('http://localhost:8765/callback?code=one-time-code') + ) + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/dashboard?devinOAuth=invalid_state' + ) + expect(response.headers.get('set-cookie')).toContain( + 'e2b-devin-oauth=; Path=/; Expires=' + ) + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(mocks.exchangeCode).not.toHaveBeenCalled() + }) + + it('preserves secure cookie attributes when clearing production state', async () => { + vi.stubEnv('NODE_ENV', 'production') + const response = await callback( + new NextRequest('https://dashboard.example.com/callback?code=invalid') + ) + + expect(response.headers.get('set-cookie')).toContain('Max-Age=0') + expect(response.headers.get('set-cookie')).toContain('HttpOnly') + expect(response.headers.get('set-cookie')).toContain('Secure') + expect(response.headers.get('set-cookie')).toContain('SameSite=lax') + }) + + it('rejects a callback after the Dashboard user changes', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.getAuthContext.mockResolvedValue({ + ...authContext, + user: { id: 'user-2' }, + }) + const response = await callback( + callbackRequest('http://localhost:8765/callback?code=one-time-code') + ) + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/dashboard/test-team/connections/devin?devinOAuth=session' + ) + expect(mocks.exchangeCode).not.toHaveBeenCalled() + }) + + it('rejects a callback when the slug resolves to a different team', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.getTeamIdFromSlug.mockResolvedValue({ ok: true, data: 'team-2' }) + const response = await callback( + callbackRequest('http://localhost:8765/callback?code=one-time-code') + ) + + expect(response.headers.get('location')).toContain('devinOAuth=access') + expect(mocks.exchangeCode).not.toHaveBeenCalled() + expect(mocks.cleanupWorker).toHaveBeenCalledWith({ + accessToken: 'dashboard-token', + sandboxId: 'sandbox-1', + teamId: 'team-1', + }) + }) + + it('recovers a completed worker without redeeming the code again', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.claimWorker.mockResolvedValue('started') + const response = await callback( + callbackRequest('http://localhost:8765/callback?code=already-used') + ) + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/dashboard/test-team/sandboxes/sandbox-1/terminal' + ) + expect(mocks.exchangeCode).not.toHaveBeenCalled() + }) + + it('preserves state while another callback owns the operation', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.claimWorker.mockResolvedValue('busy') + + const response = await callback( + callbackRequest('http://localhost:8765/callback?code=one-time-code') + ) + + expect(response.headers.get('location')).toContain('devinOAuth=in_progress') + expect(response.headers.get('set-cookie')).toBeNull() + expect(mocks.exchangeCode).not.toHaveBeenCalled() + expect(mocks.cleanupWorker).not.toHaveBeenCalled() + }) + + it('preserves state when persisted connection inspection is unavailable', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.hasPersistedConnection.mockRejectedValue( + new Error('envd unavailable') + ) + + const response = await callback( + callbackRequest('http://localhost:8765/callback?code=one-time-code') + ) + + expect(response.headers.get('location')).toContain('devinOAuth=launch') + expect(response.headers.get('set-cookie')).toBeNull() + expect(mocks.exchangeCode).not.toHaveBeenCalled() + expect(mocks.cleanupWorker).not.toHaveBeenCalled() + }) + + it('resumes worker startup from credentials persisted in the sandbox', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.hasPersistedConnection.mockResolvedValue(true) + mocks.startPersistedWorker.mockResolvedValue({ sandboxId: 'sandbox-1' }) + + const response = await callback( + callbackRequest('http://localhost:8765/callback') + ) + + expect(response.headers.get('location')).toContain( + '/sandboxes/sandbox-1/terminal' + ) + expect(mocks.exchangeCode).not.toHaveBeenCalled() + expect(mocks.startPersistedWorker).toHaveBeenCalled() + }) + + it('distinguishes provider failure from explicit authorization denial', async () => { + mocks.readAttempt.mockReturnValue(attempt) + const providerFailure = await callback( + callbackRequest('http://localhost:8765/callback?error=server_error') + ) + expect(providerFailure.headers.get('location')).toContain( + 'devinOAuth=provider' + ) + + mocks.readAttempt.mockReturnValue(attempt) + const denial = await callback( + callbackRequest('http://localhost:8765/callback?error=access_denied') + ) + expect(denial.headers.get('location')).toContain('devinOAuth=denied') + expect(denial.headers.get('set-cookie')).toBeNull() + expect(mocks.exchangeCode).not.toHaveBeenCalled() + expect(mocks.cleanupWorker).not.toHaveBeenCalled() + }) + + it('maps a consumed or expired code without launching a worker', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.exchangeCode.mockRejectedValue(new DevinOAuthError('invalid_grant')) + const response = await callback( + callbackRequest('http://localhost:8765/callback?code=expired') + ) + + expect(response.headers.get('location')).toContain('devinOAuth=expired') + expect(response.headers.get('set-cookie')).toBeNull() + expect(mocks.startPersistedWorker).not.toHaveBeenCalled() + }) + + it('exchanges the code server-side and redirects to the worker terminal', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.exchangeCode.mockResolvedValue({ + accessToken: 'scoped-token', + apiUrl: 'https://api.devin.ai', + poolId: 'pool-1', + }) + mocks.startPersistedWorker.mockResolvedValue({ sandboxId: 'sandbox-1' }) + const response = await callback( + callbackRequest('http://localhost:8765/callback?code=one-time-code') + ) + + expect(mocks.exchangeCode).toHaveBeenCalledWith('one-time-code', attempt) + expect(mocks.persistConnection).toHaveBeenCalledWith( + expect.objectContaining({ + outpostsToken: 'scoped-token', + poolId: 'pool-1', + sandboxId: 'sandbox-1', + teamId: 'team-1', + userId: 'user-1', + }) + ) + expect(mocks.startPersistedWorker).toHaveBeenCalledWith( + expect.objectContaining({ sandboxId: 'sandbox-1' }), + { cleanupOnFailure: false } + ) + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/dashboard/test-team/sandboxes/sandbox-1/terminal' + ) + expect(response.headers.get('set-cookie')).toContain( + 'e2b-devin-oauth=; Path=/; Expires=' + ) + }) + + it('recovers when credential persistence completed before its response failed', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.exchangeCode.mockResolvedValue({ + accessToken: 'scoped-token', + apiUrl: 'https://api.devin.ai', + poolId: 'pool-1', + }) + mocks.persistConnection.mockRejectedValue(new Error('response lost')) + mocks.hasPersistedConnection + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + mocks.startPersistedWorker.mockResolvedValue({ sandboxId: 'sandbox-1' }) + + const response = await callback( + callbackRequest('http://localhost:8765/callback?code=one-time-code') + ) + + expect(mocks.exchangeCode).toHaveBeenCalledTimes(1) + expect(mocks.hasPersistedConnection).toHaveBeenCalledTimes(2) + expect(mocks.startPersistedWorker).toHaveBeenCalled() + expect(response.headers.get('location')).toContain( + '/sandboxes/sandbox-1/terminal' + ) + }) +}) + +function callbackRequest(url: string) { + return new NextRequest(url, { + headers: { cookie: 'e2b-devin-oauth=signed-attempt' }, + }) +} + +function startRequest(url: string, origin = 'http://localhost:3000') { + return new NextRequest(url, { + headers: { origin }, + method: 'POST', + }) +} diff --git a/tests/unit/devin-outposts-worker.test.ts b/tests/unit/devin-outposts-worker.test.ts new file mode 100644 index 000000000..e978d0063 --- /dev/null +++ b/tests/unit/devin-outposts-worker.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + infraDelete: vi.fn(), + infraGet: vi.fn(), + infraPost: vi.fn(), + runtime: undefined as ReturnType | undefined, +})) + +vi.mock('@/core/shared/clients/api', () => ({ + infra: { + DELETE: mocks.infraDelete, + GET: mocks.infraGet, + POST: mocks.infraPost, + }, +})) + +vi.mock('e2b', () => ({ + Sandbox: class { + commands: ReturnType + + constructor() { + if (!mocks.runtime) throw new Error('missing mocked sandbox runtime') + this.commands = mocks.runtime.commands + } + }, +})) + +import { + claimPreparedDevinWorker, + DevinWorkerLaunchError, + hasPersistedDevinConnection, + launchDevinWorker, +} from '@/core/modules/devin-outposts/worker.server' + +const input = { + accessToken: 'dashboard-access-token', + apiUrl: 'https://api.devin.ai', + operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', + outpostsToken: 'scoped-outposts-token', + poolId: 'pool-1', + teamId: 'team-1', + userId: 'user-1', +} + +describe('Devin worker launcher', () => { + beforeEach(() => { + mocks.infraDelete.mockReset() + mocks.infraGet.mockReset() + mocks.infraPost.mockReset() + mocks.runtime = undefined + mocks.infraPost.mockImplementation((path: string) => { + if (path === '/sandboxes') { + return apiResult(201, sandboxApiResponse('new-sbx')) + } + if (path === '/sandboxes/{sandboxID}/connect') { + return apiResult(200, sandboxApiResponse('new-sbx')) + } + throw new Error(`unexpected path ${path}`) + }) + mocks.infraDelete.mockResolvedValue(apiResult(204)) + mocks.infraGet.mockResolvedValue(apiResult(200, [])) + }) + + it('starts the worker through the bearer-authenticated sandbox API', async () => { + const sandbox = sandboxWithResults([ + { exitCode: 0, stdout: '' }, + { exitCode: 0, stdout: 'stopped' }, + { exitCode: 0, stdout: '4242' }, + ]) + mocks.runtime = sandbox + + await expect(launchDevinWorker(input)).resolves.toMatchObject({ + reused: false, + sandboxId: 'new-sbx', + workerPid: '4242', + }) + + expect(mocks.infraPost).toHaveBeenNthCalledWith( + 1, + '/sandboxes', + expect.objectContaining({ + body: expect.objectContaining({ + autoPause: false, + autoResume: { enabled: false }, + timeout: 1800, + }), + headers: { + Authorization: 'Bearer dashboard-access-token', + 'X-Team-ID': 'team-1', + }, + }) + ) + expect(mocks.infraPost).toHaveBeenNthCalledWith( + 4, + '/sandboxes/{sandboxID}/connect', + expect.objectContaining({ + body: { timeout: 86_400 }, + params: { path: { sandboxID: 'new-sbx' } }, + }) + ) + }) + + it('reuses a worker that is already marked as running', async () => { + mocks.infraGet.mockResolvedValue( + apiResult(200, [sandboxApiResponse('existing-sbx')]) + ) + mocks.infraPost.mockImplementation((path: string) => { + if (path === '/sandboxes/{sandboxID}/connect') { + return apiResult(200, sandboxApiResponse('existing-sbx')) + } + throw new Error(`unexpected path ${path}`) + }) + mocks.runtime = sandboxWithResults([ + { exitCode: 0, stdout: '' }, + { exitCode: 0, stdout: 'running' }, + ]) + + await expect(launchDevinWorker(input)).resolves.toMatchObject({ + reused: true, + sandboxId: 'existing-sbx', + workerPid: null, + }) + expect(mocks.runtime.commands.run).toHaveBeenCalledTimes(2) + expect(mocks.infraPost).not.toHaveBeenCalledWith( + '/sandboxes', + expect.anything() + ) + }) + + it('keeps the scoped credential out of metadata and command text', async () => { + const sandbox = sandboxWithResults([ + { exitCode: 0, stdout: '' }, + { exitCode: 0, stdout: 'stopped' }, + { exitCode: 0, stdout: '4242' }, + ]) + mocks.runtime = sandbox + + await launchDevinWorker(input) + + const createCall = mocks.infraPost.mock.calls[0]?.[1] + expect(createCall.body.metadata).not.toEqual( + expect.objectContaining({ token: expect.anything() }) + ) + const persistCall = sandbox.commands.run.mock.calls[0] + const startCall = sandbox.commands.run.mock.calls[2] + expect(startCall?.[0]).not.toContain(input.outpostsToken) + expect(persistCall?.[1]).toEqual( + expect.objectContaining({ + envs: expect.objectContaining({ + DEVIN_OUTPOSTS_TOKEN: input.outpostsToken, + }), + }) + ) + }) + + it('kills the prepared sandbox when worker startup fails', async () => { + mocks.runtime = sandboxWithResults([ + { exitCode: 0, stdout: '' }, + { exitCode: 0, stdout: 'stopped' }, + { exitCode: 1, stdout: '' }, + ]) + + await expect(launchDevinWorker(input)).rejects.toBeInstanceOf( + DevinWorkerLaunchError + ) + expect(mocks.infraDelete).toHaveBeenCalledWith( + '/sandboxes/{sandboxID}', + expect.objectContaining({ + params: { path: { sandboxID: 'new-sbx' } }, + }) + ) + }) + + it('reports a prepared sandbox that could not be cleaned up', async () => { + mocks.runtime = sandboxWithResults([ + { exitCode: 0, stdout: '' }, + { exitCode: 0, stdout: 'stopped' }, + { exitCode: 1, stdout: '' }, + ]) + mocks.infraDelete.mockResolvedValue(apiResult(500)) + + await expect(launchDevinWorker(input)).rejects.toMatchObject({ + orphanedSandboxId: 'new-sbx', + }) + }) + + it('uses an atomic sandbox lock to serialize OAuth callbacks', async () => { + mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'busy\n' }]) + + await expect( + claimPreparedDevinWorker({ ...input, sandboxId: 'new-sbx' }) + ).resolves.toBe('busy') + + const command = mocks.runtime.commands.run.mock.calls[0]?.[0] + expect(command).toContain('mkdir') + expect(command).toContain('callback.lock') + expect(command).toContain('claimed-at') + expect(command).toContain('-gt 120') + }) + + it('renews a worker recovered from its PID marker', async () => { + mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'started\n' }]) + + await expect( + claimPreparedDevinWorker({ ...input, sandboxId: 'new-sbx' }) + ).resolves.toBe('started') + + expect(mocks.infraPost).toHaveBeenNthCalledWith( + 2, + '/sandboxes/{sandboxID}/connect', + expect.objectContaining({ body: { timeout: 86_400 } }) + ) + }) + + it.each([ + ['present', true], + ['missing', false], + ] as const)('reads persisted connection state: %s', async (state, expected) => { + mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: state }]) + + await expect( + hasPersistedDevinConnection({ ...input, sandboxId: 'new-sbx' }) + ).resolves.toBe(expected) + + const command = mocks.runtime.commands.run.mock.calls[0]?.[0] + expect(command).toContain('then printf present') + expect(command).toContain('else printf missing') + }) + + it('rejects an invalid persisted connection probe result', async () => { + mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'unknown' }]) + + await expect( + hasPersistedDevinConnection({ ...input, sandboxId: 'new-sbx' }) + ).rejects.toBeInstanceOf(DevinWorkerLaunchError) + }) +}) + +function sandboxWithResults( + results: Array<{ exitCode: number; stdout: string }> +) { + return { + commands: { run: vi.fn().mockImplementation(() => results.shift()) }, + } +} + +function sandboxApiResponse(sandboxId: string) { + return { + clientID: 'client-1', + domain: 'e2b.app', + envdAccessToken: 'envd-token', + envdVersion: '0.1.0', + sandboxID: sandboxId, + templateID: 'devin-outposts', + } +} + +function apiResult(status: number, data?: unknown) { + return { + data, + response: { ok: status >= 200 && status < 300, status }, + } +} From 6018f876c03b938104a6acde280ecea608e0ac94 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Mon, 13 Jul 2026 14:15:39 -0700 Subject: [PATCH 03/16] Authorize Devin before provisioning workers Defer sandbox creation until Devin returns the scoped Outposts credential, and make consumed-code failures restart with fresh PKCE state. Add explicit running/paused worker disconnect while keeping provider-side service-user revocation an admin operation. --- src/app/api/connections/devin/start/route.ts | 44 +-- src/app/callback/route.ts | 120 ++----- .../modules/devin-outposts/oauth.server.ts | 74 +++-- .../modules/devin-outposts/worker.server.ts | 226 +++++++------ src/core/server/api/routers/connections.ts | 20 +- .../connections/devin-connection-form.tsx | 217 ++++++++---- tests/unit/devin-outposts-oauth.test.ts | 47 ++- tests/unit/devin-outposts-routes.test.ts | 310 +++++------------- tests/unit/devin-outposts-worker.test.ts | 139 +++++--- 9 files changed, 590 insertions(+), 607 deletions(-) diff --git a/src/app/api/connections/devin/start/route.ts b/src/app/api/connections/devin/start/route.ts index f63feace0..88453745f 100644 --- a/src/app/api/connections/devin/start/route.ts +++ b/src/app/api/connections/devin/start/route.ts @@ -12,11 +12,6 @@ import { isDevinOAuthConfigured, readDevinOAuthAttempt, } from '@/core/modules/devin-outposts/oauth.server' -import { - cleanupPreparedDevinWorker, - isPreparedDevinWorkerAvailable, - prepareDevinWorkerSandbox, -} from '@/core/modules/devin-outposts/worker.server' import { getAuthContext } from '@/core/server/auth' import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' import { TeamSlugSchema } from '@/core/shared/schemas/team' @@ -67,42 +62,20 @@ export async function POST(request: NextRequest) { return redirectToConnection(publicOrigin, activePath, 'in_progress') } - const available = await isPreparedDevinWorkerAvailable({ - accessToken: authContext.accessToken, - operationId: activeAttempt.operationId, - sandboxId: activeAttempt.sandboxId, - teamId: activeAttempt.teamId, - userId: activeAttempt.userId, - }) - if (available) { - const response = NextResponse.redirect( - getDevinOAuthConnectUrl(activeAttempt), - 303 - ) - response.headers.set('Cache-Control', 'no-store') - return response - } + const response = NextResponse.redirect( + getDevinOAuthConnectUrl(activeAttempt), + 303 + ) + response.headers.set('Cache-Control', 'no-store') + return response } const operationId = randomUUID() - let sandboxId: string - try { - const prepared = await prepareDevinWorkerSandbox({ - accessToken: authContext.accessToken, - operationId, - teamId: teamIdResult.data, - userId: authContext.user.id, - }) - sandboxId = prepared.sandboxId - } catch { - return redirectToConnection(publicOrigin, connectionPath, 'launch') - } try { const { attemptCookie, connectUrl } = createDevinOAuthAttempt({ operationId, returnOrigin: publicOrigin, - sandboxId, teamId: teamIdResult.data, teamSlug, userId: authContext.user.id, @@ -116,11 +89,6 @@ export async function POST(request: NextRequest) { response.headers.set('Cache-Control', 'no-store') return response } catch { - await cleanupPreparedDevinWorker({ - accessToken: authContext.accessToken, - sandboxId, - teamId: teamIdResult.data, - }).catch(() => undefined) return redirectToConnection(publicOrigin, connectionPath, 'config') } } diff --git a/src/app/callback/route.ts b/src/app/callback/route.ts index eac4f0961..28debb551 100644 --- a/src/app/callback/route.ts +++ b/src/app/callback/route.ts @@ -11,12 +11,9 @@ import { readDevinOAuthAttempt, } from '@/core/modules/devin-outposts/oauth.server' import { - claimPreparedDevinWorker, - cleanupPreparedDevinWorker, DevinWorkerLaunchError, - hasPersistedDevinConnection, - persistPreparedDevinConnection, - startPersistedDevinWorker, + findStartedDevinWorker, + launchDevinWorker, } from '@/core/modules/devin-outposts/worker.server' import { getAuthContext } from '@/core/server/auth' import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' @@ -52,19 +49,16 @@ export async function GET(request: NextRequest) { authContext.accessToken ) if (!teamIdResult.ok) { - await cleanupAttemptSandbox(authContext.accessToken, attempt) return finish( connectionRedirect(attempt.returnOrigin, connectionPath, 'dashboard') ) } if (!teamIdResult.data) { - await cleanupAttemptSandbox(authContext.accessToken, attempt) return finish( connectionRedirect(attempt.returnOrigin, connectionPath, 'access') ) } if (teamIdResult.data !== attempt.teamId) { - await cleanupAttemptSandbox(authContext.accessToken, attempt) return finish( connectionRedirect(attempt.returnOrigin, connectionPath, 'access') ) @@ -73,65 +67,32 @@ export async function GET(request: NextRequest) { const workerInput = { accessToken: authContext.accessToken, operationId: attempt.operationId, - sandboxId: attempt.sandboxId, teamId: attempt.teamId, userId: authContext.user.id, } - let claim: 'busy' | 'claimed' | 'started' - try { - claim = await claimPreparedDevinWorker(workerInput) - } catch (error) { - l.warn( - { - key: 'devin:oauth_callback_claim_failed', - context: { - error_name: error instanceof Error ? error.name : 'unknown', - reason: - error instanceof Error && - /^sandbox_[a-z_]+(?:_\d{3})?$/.test(error.message) - ? error.message - : 'unknown', - }, - sandbox_id: attempt.sandboxId, - team_id: attempt.teamId, - user_id: authContext.user.id, - }, - '[Devin] Could not claim the prepared worker callback' - ) - return preserve( - connectionRedirect(attempt.returnOrigin, connectionPath, 'launch') - ) - } - if (claim === 'started') { - return finish( - terminalRedirect( - attempt.returnOrigin, - attempt.teamSlug, - attempt.sandboxId - ) - ) - } - if (claim === 'busy') { - return preserve( - connectionRedirect(attempt.returnOrigin, connectionPath, 'in_progress') - ) - } - let credentialsPersisted = false try { - credentialsPersisted = await hasPersistedDevinConnection(workerInput) + const existingSandboxId = await findStartedDevinWorker(workerInput) + if (existingSandboxId) { + return finish( + terminalRedirect( + attempt.returnOrigin, + attempt.teamSlug, + existingSandboxId + ) + ) + } } catch (error) { l.warn( { - key: 'devin:oauth_persisted_state_check_failed', + key: 'devin:oauth_worker_recovery_failed', context: { error_name: error instanceof Error ? error.name : 'unknown', }, - sandbox_id: attempt.sandboxId, team_id: attempt.teamId, user_id: authContext.user.id, }, - '[Devin] Could not inspect persisted worker state' + '[Devin] Could not inspect an existing OAuth worker' ) return preserve( connectionRedirect(attempt.returnOrigin, connectionPath, 'launch') @@ -141,7 +102,7 @@ export async function GET(request: NextRequest) { const codes = request.nextUrl.searchParams.getAll('code') const providerErrors = request.nextUrl.searchParams.getAll('error') const code = codes.length === 1 ? codes[0] : undefined - if (!credentialsPersisted && (!code || code.length > 4096)) { + if (!code || code.length > 4096) { const explicitlyDenied = providerErrors.length === 1 && providerErrors[0] === 'access_denied' return preserve( @@ -153,26 +114,15 @@ export async function GET(request: NextRequest) { ) } - let exchangeCompleted = false + let codeExchanged = false try { - if (!credentialsPersisted) { - const token = await exchangeDevinConnectionCode(code as string, attempt) - exchangeCompleted = true - try { - await persistPreparedDevinConnection({ - ...workerInput, - apiUrl: token.apiUrl, - outpostsToken: token.accessToken, - poolId: token.poolId, - }) - credentialsPersisted = true - } catch { - credentialsPersisted = await hasPersistedDevinConnection(workerInput) - if (!credentialsPersisted) throw new DevinWorkerLaunchError() - } - } - const worker = await startPersistedDevinWorker(workerInput, { - cleanupOnFailure: false, + const token = await exchangeDevinConnectionCode(code, attempt) + codeExchanged = true + const worker = await launchDevinWorker({ + ...workerInput, + apiUrl: token.apiUrl, + outpostsToken: token.accessToken, + poolId: token.poolId, }) return finish( terminalRedirect(attempt.returnOrigin, attempt.teamSlug, worker.sandboxId) @@ -193,11 +143,14 @@ export async function GET(request: NextRequest) { }, '[Devin] OAuth connection did not complete' ) - return credentialsPersisted || exchangeCompleted || status === 'expired' - ? preserve( - connectionRedirect(attempt.returnOrigin, connectionPath, status) - ) - : finish(connectionRedirect(attempt.returnOrigin, connectionPath, status)) + const redirect = connectionRedirect( + attempt.returnOrigin, + connectionPath, + status + ) + return codeExchanged || status === 'expired' + ? finish(redirect) + : preserve(redirect) } } @@ -207,17 +160,6 @@ function terminalRedirect(origin: string, teamSlug: string, sandboxId: string) { ) } -async function cleanupAttemptSandbox( - accessToken: string, - attempt: { sandboxId: string; teamId: string } -) { - await cleanupPreparedDevinWorker({ - accessToken, - sandboxId: attempt.sandboxId, - teamId: attempt.teamId, - }).catch(() => undefined) -} - function connectionRedirect(origin: string, path: string, status: string) { const url = new URL(path, origin) url.searchParams.set('devinOAuth', status) diff --git a/src/core/modules/devin-outposts/oauth.server.ts b/src/core/modules/devin-outposts/oauth.server.ts index dcd2affb0..a5e23fb43 100644 --- a/src/core/modules/devin-outposts/oauth.server.ts +++ b/src/core/modules/devin-outposts/oauth.server.ts @@ -9,32 +9,27 @@ import { import { z } from 'zod' import { normalizeDevinApiUrl } from './client.server' -const DEVIN_CONNECT_URL = 'https://app.devin.ai/outposts/connect' +const DEFAULT_DEVIN_CONNECT_URL = 'https://app.devin.ai/outposts/connect' const DEVIN_TOKEN_URL = 'https://api.devin.ai/outposts/connection-token' const OAUTH_ATTEMPT_TTL_SECONDS = 30 * 60 const REQUEST_TIMEOUT_MS = 15_000 -const oauthAttemptSchema = z - .object({ - expiresAt: z.number().int().positive(), - nonce: z.string().min(32).max(128), - operationId: z.string().uuid(), - returnOrigin: z.string().url(), - sandboxId: z.string().min(1).max(256), - teamId: z.string().min(1).max(256), - teamSlug: z.string().min(1).max(256), - userId: z.string().min(1).max(256), - version: z.literal(1), - }) - .strict() - -const tokenResponseSchema = z - .object({ - access_token: z.string().min(1).max(8192), - api_base_url: z.string().min(1).max(512), - outpost_pool_id: z.string().min(1).max(256), - }) - .passthrough() +const oauthAttemptSchema = z.strictObject({ + expiresAt: z.number().int().positive(), + nonce: z.string().min(32).max(128), + operationId: z.uuid(), + returnOrigin: z.url(), + teamId: z.string().min(1).max(256), + teamSlug: z.string().min(1).max(256), + userId: z.string().min(1).max(256), + version: z.literal(1), +}) + +const tokenResponseSchema = z.looseObject({ + access_token: z.string().min(1).max(8192), + api_base_url: z.string().min(1).max(512), + outpost_pool_id: z.string().min(1).max(256), +}) export type DevinOAuthAttempt = z.infer @@ -55,6 +50,7 @@ export class DevinOAuthError extends Error { export function isDevinOAuthConfigured(returnOrigin: string) { try { validateCallbackHost(getCallbackUrl(), normalizeReturnOrigin(returnOrigin)) + getConnectUrl() getSecret() return true } catch { @@ -65,7 +61,6 @@ export function isDevinOAuthConfigured(returnOrigin: string) { export function createDevinOAuthAttempt(input: { operationId: string returnOrigin: string - sandboxId: string teamId: string teamSlug: string userId: string @@ -75,7 +70,6 @@ export function createDevinOAuthAttempt(input: { nonce: randomBytes(32).toString('base64url'), operationId: input.operationId, returnOrigin: normalizeReturnOrigin(input.returnOrigin), - sandboxId: input.sandboxId, teamId: input.teamId, teamSlug: input.teamSlug, userId: input.userId, @@ -91,7 +85,7 @@ export function getDevinOAuthConnectUrl(attempt: DevinOAuthAttempt) { const verifier = deriveVerifier(attempt) const callbackUrl = getCallbackUrl() validateCallbackHost(callbackUrl, attempt.returnOrigin) - const connectUrl = new URL(DEVIN_CONNECT_URL) + const connectUrl = getConnectUrl() connectUrl.searchParams.set('callback_url', callbackUrl) connectUrl.searchParams.set('code_challenge', pkceChallenge(verifier)) connectUrl.searchParams.set('platform', 'linux') @@ -235,6 +229,36 @@ function getCallbackUrl() { return url.toString() } +function getConnectUrl() { + const value = + process.env.DEVIN_OUTPOSTS_CONNECT_URL || DEFAULT_DEVIN_CONNECT_URL + let url: URL + try { + url = new URL(value) + } catch { + throw new DevinOAuthError('config') + } + const hostname = url.hostname.toLowerCase() + const devinOwnedHost = + hostname === 'devin.ai' || + hostname.endsWith('.devin.ai') || + hostname === 'devinenterprise.com' || + hostname.endsWith('.devinenterprise.com') + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + url.pathname !== '/outposts/connect' || + url.search || + url.hash || + !devinOwnedHost + ) { + throw new DevinOAuthError('config') + } + return url +} + function normalizeReturnOrigin(value: string) { const url = new URL(value) const localHttp = diff --git a/src/core/modules/devin-outposts/worker.server.ts b/src/core/modules/devin-outposts/worker.server.ts index 390cd8002..d988c1f71 100644 --- a/src/core/modules/devin-outposts/worker.server.ts +++ b/src/core/modules/devin-outposts/worker.server.ts @@ -9,7 +9,6 @@ import { normalizeDevinApiUrl } from './client.server' const ACTIVE_WORKER_TIMEOUT_MS = 24 * 60 * 60 * 1000 const API_REQUEST_TIMEOUT_MS = 10_000 -const CALLBACK_LEASE_SECONDS = 2 * 60 const DEFAULT_DEVIN_TEMPLATE = 'devin-outposts' const PREPARED_SANDBOX_TIMEOUT_MS = 30 * 60 * 1000 const WORKER_START_TIMEOUT_MS = 15_000 @@ -27,13 +26,13 @@ export type LaunchDevinWorkerInput = WorkerIdentity & { poolId: string } -export type StartPreparedDevinWorkerInput = LaunchDevinWorkerInput & { +type StartPreparedDevinWorkerInput = LaunchDevinWorkerInput & { sandboxId: string } type PreparedWorkerIdentity = WorkerIdentity & { sandboxId: string } -export type PreparedDevinWorker = { +type PreparedDevinWorker = { acceptorId: string sandboxId: string started: boolean @@ -55,14 +54,45 @@ export class DevinWorkerLaunchError extends Error { export async function launchDevinWorker( input: LaunchDevinWorkerInput ): Promise { - const existingSandboxId = await findRunningWorkerSandbox(input) + const existingSandboxId = await findWorkerSandbox(input) const prepared = existingSandboxId ? { sandboxId: existingSandboxId } : await prepareDevinWorkerSandbox(input) return startPreparedDevinWorker({ ...input, sandboxId: prepared.sandboxId }) } -export async function prepareDevinWorkerSandbox( +export async function findStartedDevinWorker(input: WorkerIdentity) { + const sandboxId = await findWorkerSandbox(input) + if (!sandboxId) return null + + const sandbox = await connectWorkerSandbox( + { ...input, sandboxId }, + PREPARED_SANDBOX_TIMEOUT_MS + ) + if (!(await sandboxHasStartedWorker(sandbox, input.operationId))) return null + await extendWorkerSandbox({ ...input, sandboxId }, ACTIVE_WORKER_TIMEOUT_MS) + return sandboxId +} + +export async function disconnectDevinWorkers( + input: Pick +) { + const result = await listDisconnectableWorkerSandboxes(input) + const sandboxIds = result.map((sandbox) => sandbox.sandboxID) + const deleted = await Promise.all( + sandboxIds.map((sandboxId) => + cleanupPreparedDevinWorker({ + accessToken: input.accessToken, + sandboxId, + teamId: input.teamId, + }) + ) + ) + if (deleted.some((success) => !success)) throw new DevinWorkerLaunchError() + return { count: sandboxIds.length } +} + +async function prepareDevinWorkerSandbox( input: WorkerIdentity ): Promise { const acceptorId = acceptorIdFor(input.operationId) @@ -87,88 +117,7 @@ export async function prepareDevinWorkerSandbox( } } -export async function isPreparedDevinWorkerStarted( - input: PreparedWorkerIdentity -) { - const sandbox = await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) - return sandboxHasStartedWorker(sandbox, input.operationId) -} - -export async function isPreparedDevinWorkerAvailable( - input: PreparedWorkerIdentity -) { - try { - await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) - return true - } catch { - return false - } -} - -export async function claimPreparedDevinWorker( - input: PreparedWorkerIdentity -): Promise<'busy' | 'claimed' | 'started'> { - const sandbox = await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) - const stateDir = stateDirFor(input.operationId) - const lockDir = `${stateDir}/callback.lock` - const result = await sandbox.commands.run( - [ - `mkdir -p ${shellQuote(stateDir)}`, - `if worker_pid=$(cat ${shellQuote(workerMarkerPath(input.operationId))} 2>/dev/null) && kill -0 "$worker_pid" 2>/dev/null; then echo started; exit 0; fi`, - `if mkdir ${shellQuote(lockDir)} 2>/dev/null; then date +%s > ${shellQuote(`${lockDir}/claimed-at`)}; echo claimed; exit 0; fi`, - `claimed_at=$(cat ${shellQuote(`${lockDir}/claimed-at`)} 2>/dev/null || echo 0)`, - 'now=$(date +%s)', - `if [ $((now - claimed_at)) -gt ${CALLBACK_LEASE_SECONDS} ]; then rm -rf ${shellQuote(lockDir)} && mkdir ${shellQuote(lockDir)} && date +%s > ${shellQuote(`${lockDir}/claimed-at`)} && echo claimed; else echo busy; fi`, - ].join('\n'), - { requestTimeoutMs: API_REQUEST_TIMEOUT_MS } - ) - const state = result.stdout.trim() - if ( - result.exitCode !== 0 || - !['busy', 'claimed', 'started'].includes(state) - ) { - l.warn( - { - key: 'devin:worker_claim_invalid_result', - context: { - exit_code: result.exitCode, - state: ['busy', 'claimed', 'started'].includes(state) - ? state - : 'invalid', - }, - sandbox_id: input.sandboxId, - team_id: input.teamId, - user_id: input.userId, - }, - '[Devin] Worker callback claim returned an invalid result' - ) - throw new DevinWorkerLaunchError(input.sandboxId) - } - if (state === 'started') { - await extendWorkerSandbox(input, ACTIVE_WORKER_TIMEOUT_MS) - } - return state as 'busy' | 'claimed' | 'started' -} - -export async function hasPersistedDevinConnection( - input: PreparedWorkerIdentity -) { - const sandbox = await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) - const check = connectionFilePaths(input.operationId) - .map((path) => `test -s ${shellQuote(path)}`) - .join(' && ') - const result = await sandbox.commands.run( - `if ${check}; then printf present; else printf missing; fi`, - { requestTimeoutMs: API_REQUEST_TIMEOUT_MS } - ) - const state = result.stdout.trim() - if (result.exitCode !== 0 || !['missing', 'present'].includes(state)) { - throw new DevinWorkerLaunchError(input.sandboxId) - } - return state === 'present' -} - -export async function persistPreparedDevinConnection( +async function persistPreparedDevinConnection( input: StartPreparedDevinWorkerInput ) { const sandbox = await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) @@ -195,17 +144,19 @@ export async function persistPreparedDevinConnection( if (result.exitCode !== 0) throw new DevinWorkerLaunchError(input.sandboxId) } -export async function startPreparedDevinWorker( - input: StartPreparedDevinWorkerInput, - options: { cleanupOnFailure?: boolean } = {} +async function startPreparedDevinWorker( + input: StartPreparedDevinWorkerInput ): Promise { - await persistPreparedDevinConnection(input) - return startPersistedDevinWorker(input, options) + try { + await persistPreparedDevinConnection(input) + } catch { + return cleanupFailedWorkerLaunch(input) + } + return startPersistedDevinWorker(input) } -export async function startPersistedDevinWorker( - input: PreparedWorkerIdentity, - options: { cleanupOnFailure?: boolean } = {} +async function startPersistedDevinWorker( + input: PreparedWorkerIdentity ): Promise { const acceptorId = acceptorIdFor(input.operationId) const stateDir = stateDirFor(input.operationId) @@ -258,23 +209,26 @@ export async function startPersistedDevinWorker( workerPid: result.stdout.trim(), } } catch { - const cleaned = - options.cleanupOnFailure === false - ? true - : await cleanupPreparedDevinWorker(input).catch(() => false) - if (!cleaned) { - l.error( - { - key: 'devin:worker_sandbox_cleanup_failed', - sandbox_id: input.sandboxId, - team_id: input.teamId, - user_id: input.userId, - }, - '[Devin] Failed to clean up worker sandbox after launch failure' - ) - } - throw new DevinWorkerLaunchError(cleaned ? undefined : input.sandboxId) + return cleanupFailedWorkerLaunch(input) + } +} + +async function cleanupFailedWorkerLaunch( + input: PreparedWorkerIdentity +): Promise { + const cleaned = await cleanupPreparedDevinWorker(input).catch(() => false) + if (!cleaned) { + l.error( + { + key: 'devin:worker_sandbox_cleanup_failed', + sandbox_id: input.sandboxId, + team_id: input.teamId, + user_id: input.userId, + }, + '[Devin] Failed to clean up worker sandbox after launch failure' + ) } + throw new DevinWorkerLaunchError(cleaned ? undefined : input.sandboxId) } async function extendWorkerSandbox( @@ -309,7 +263,7 @@ async function sandboxHasStartedWorker(sandbox: Sandbox, operationId: string) { return state === 'running' } -export function cleanupPreparedDevinWorker( +function cleanupPreparedDevinWorker( input: Pick & { sandboxId: string } ) { return infra @@ -330,9 +284,18 @@ function connectionOptions(accessToken: string, teamId: string) { } } -async function findRunningWorkerSandbox(input: WorkerIdentity) { +async function findWorkerSandbox(input: WorkerIdentity) { + const sandboxes = await listWorkerSandboxes(input) + return sandboxes.find( + (sandbox) => sandbox.metadata?.devinLaunchOperationId === input.operationId + )?.sandboxID +} + +async function listWorkerSandboxes( + input: Pick +) { const metadata = new URLSearchParams({ - devinLaunchOperationId: input.operationId, + source: 'dashboard-devin-outposts', userId: input.userId, }).toString() const result = await infra.GET('/sandboxes', { @@ -343,7 +306,40 @@ async function findRunningWorkerSandbox(input: WorkerIdentity) { if (!result.response.ok || !result.data) { throw new DevinWorkerLaunchError() } - return result.data[0]?.sandboxID + return result.data +} + +async function listDisconnectableWorkerSandboxes( + input: Pick +) { + const metadata = new URLSearchParams({ + source: 'dashboard-devin-outposts', + userId: input.userId, + }).toString() + const sandboxes: InfraComponents['schemas']['ListedSandbox'][] = [] + let nextToken: string | undefined + + do { + const result = await infra.GET('/v2/sandboxes', { + headers: authHeaders(input.accessToken, input.teamId), + params: { + query: { + limit: 100, + metadata, + nextToken, + state: ['running', 'paused'], + }, + }, + signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS), + }) + if (!result.response.ok || !result.data) { + throw new DevinWorkerLaunchError() + } + sandboxes.push(...result.data) + nextToken = result.response.headers.get('X-Next-Token') || undefined + } while (nextToken) + + return sandboxes } async function createWorkerSandbox(input: WorkerIdentity) { diff --git a/src/core/server/api/routers/connections.ts b/src/core/server/api/routers/connections.ts index 149b8eef3..58a27c304 100644 --- a/src/core/server/api/routers/connections.ts +++ b/src/core/server/api/routers/connections.ts @@ -7,12 +7,30 @@ import { } from '@/core/modules/devin-outposts/client.server' import { DevinWorkerLaunchError, + disconnectDevinWorkers, launchDevinWorker, } from '@/core/modules/devin-outposts/worker.server' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' export const connectionsRouter = createTRPCRouter({ + disconnectDevinWorkers: protectedTeamProcedure + .input(z.object({ confirm: z.literal(true) })) + .mutation(async ({ ctx }) => { + try { + return await disconnectDevinWorkers({ + accessToken: ctx.session.access_token, + teamId: ctx.teamId, + userId: ctx.session.user.id, + }) + } catch { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Could not disconnect the Devin workers', + }) + } + }), + discoverDevin: protectedTeamProcedure .input( z.object({ @@ -46,7 +64,7 @@ export const connectionsRouter = createTRPCRouter({ .input( z.object({ apiUrl: z.string().trim().min(1).max(512), - operationId: z.string().uuid(), + operationId: z.uuid(), outpostsToken: z.string().trim().min(1).max(4096), poolId: z.string().trim().min(1).max(256), }) diff --git a/src/features/dashboard/connections/devin-connection-form.tsx b/src/features/dashboard/connections/devin-connection-form.tsx index b1245cbaa..bf7361b05 100644 --- a/src/features/dashboard/connections/devin-connection-form.tsx +++ b/src/features/dashboard/connections/devin-connection-form.tsx @@ -9,11 +9,13 @@ import { toast, } from '@/lib/hooks/use-toast' import { useTRPCClient } from '@/trpc/client' +import { AlertDialog } from '@/ui/alert-dialog' import { Button } from '@/ui/primitives/button' import { CheckCircleIcon, ExternalLinkIcon, KeyIcon, + LogOutIcon, TerminalIcon, } from '@/ui/primitives/icons' import { Input } from '@/ui/primitives/input' @@ -39,6 +41,149 @@ export function DevinConnectionForm({ oauthMessage, teamSlug, }: DevinConnectionFormProps) { + return ( +
+
+
+
+ +
+
+

Connection

+

Devin Outposts

+
+
+

+ Discover your Devin organizations and pools from the dashboard, then + start a worker using only a scoped machine token inside the sandbox. +

+
+ + + +
+ ) +} + +function DevinOAuthConnection({ + oauthEnabled, + oauthMessage, + teamSlug, +}: DevinConnectionFormProps) { + const trpcClient = useTRPCClient() + const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false) + const [disconnectPending, setDisconnectPending] = useState(false) + + async function disconnectWorkers() { + if (disconnectPending) return + setDisconnectPending(true) + try { + const data = await trpcClient.connections.disconnectDevinWorkers.mutate({ + confirm: true, + teamSlug, + }) + toast( + defaultSuccessToast( + data.count === 0 + ? 'No E2B Devin workers were running.' + : `Disconnected ${data.count} E2B Devin ${data.count === 1 ? 'worker' : 'workers'}.` + ) + ) + setDisconnectDialogOpen(false) + } catch (error) { + toast( + defaultErrorToast( + error instanceof Error && error.message + ? error.message + : 'Could not disconnect the Devin workers.' + ) + ) + } + setDisconnectPending(false) + } + + return ( +
+
+ +
+

Connect with Devin

+

+ Authorize E2B in Devin. Devin creates a dedicated pool and scoped + service user. After approval, the dashboard creates the worker + sandbox and injects the scoped credential without putting it in + browser state. +

+
+
+ + {oauthMessage ? ( +

+ {oauthMessage} +

+ ) : null} + +
+ {oauthEnabled ? ( +
+ +
+ ) : ( + + )} + + Disconnect workers + + + } + /> +
+

+ Disconnecting stops the E2B workers. To revoke Devin access entirely, + delete the generated service user in Devin enterprise settings. +

+ {!oauthEnabled ? ( +

+ Partner OAuth is not configured for this deployment. Use the manual + setup below. +

+ ) : null} +
+ ) +} + +function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { const router = useRouter() const trpcClient = useTRPCClient() const apiUrlRef = useRef(null) @@ -136,75 +281,7 @@ export function DevinConnectionForm({ } return ( -
-
-
-
- -
-
-

Connection

-

Devin Outposts

-
-
-

- Discover your Devin organizations and pools from the dashboard, then - start a worker using only a scoped machine token inside the sandbox. -

-
- -
-
- -
-

Connect with Devin

-

- Authorize E2B in Devin. Devin creates a dedicated pool and scoped - service user, then the dashboard stores the scoped worker - credential directly in the prepared sandbox. It never enters - browser state. -

-
-
- - {oauthMessage ? ( -

- {oauthMessage} -

- ) : null} - -
- {oauthEnabled ? ( -
- -
- ) : ( - - )} -
- {!oauthEnabled ? ( -

- Partner OAuth is not configured for this deployment. Use the manual - setup below. -

- ) : null} -
- + <>
@@ -356,7 +433,7 @@ export function DevinConnectionForm({
-
+ ) } diff --git a/tests/unit/devin-outposts-oauth.test.ts b/tests/unit/devin-outposts-oauth.test.ts index faf59f11f..2331356fd 100644 --- a/tests/unit/devin-outposts-oauth.test.ts +++ b/tests/unit/devin-outposts-oauth.test.ts @@ -11,11 +11,13 @@ import { const ORIGINAL_AUTH_SECRET = process.env.AUTH_SECRET const ORIGINAL_CALLBACK_URL = process.env.DEVIN_OUTPOSTS_CALLBACK_URL +const ORIGINAL_CONNECT_URL = process.env.DEVIN_OUTPOSTS_CONNECT_URL describe('Devin partner OAuth boundary', () => { beforeEach(() => { process.env.AUTH_SECRET = 'test-auth-secret-with-enough-entropy' process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'http://localhost:8765/callback' + delete process.env.DEVIN_OUTPOSTS_CONNECT_URL }) afterEach(() => { @@ -24,13 +26,13 @@ describe('Devin partner OAuth boundary', () => { vi.unstubAllEnvs() restoreEnv('AUTH_SECRET', ORIGINAL_AUTH_SECRET) restoreEnv('DEVIN_OUTPOSTS_CALLBACK_URL', ORIGINAL_CALLBACK_URL) + restoreEnv('DEVIN_OUTPOSTS_CONNECT_URL', ORIGINAL_CONNECT_URL) }) it('creates signed state and a matching S256 challenge', async () => { const { attemptCookie, connectUrl } = createDevinOAuthAttempt({ operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', returnOrigin: 'http://localhost:3000', - sandboxId: 'sandbox-1', teamId: 'team-1', teamSlug: 'test-team', userId: 'user-1', @@ -38,7 +40,6 @@ describe('Devin partner OAuth boundary', () => { const attempt = readDevinOAuthAttempt(attemptCookie) expect(attempt).toMatchObject({ returnOrigin: 'http://localhost:3000', - sandboxId: 'sandbox-1', teamId: 'team-1', teamSlug: 'test-team', userId: 'user-1', @@ -82,7 +83,6 @@ describe('Devin partner OAuth boundary', () => { const { attemptCookie } = createDevinOAuthAttempt({ operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', returnOrigin: 'http://localhost:3000', - sandboxId: 'sandbox-1', teamId: 'team-1', teamSlug: 'test-team', userId: 'user-1', @@ -93,11 +93,39 @@ describe('Devin partner OAuth boundary', () => { expect(readDevinOAuthAttempt(attemptCookie)).toBeNull() }) + it('supports a Devin enterprise connect host without changing token exchange', async () => { + process.env.DEVIN_OUTPOSTS_CONNECT_URL = + 'https://e2b.beta.devinenterprise.com/outposts/connect' + const { attemptCookie, connectUrl } = createDevinOAuthAttempt({ + operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', + returnOrigin: 'http://localhost:3000', + teamId: 'team-1', + teamSlug: 'test-team', + userId: 'user-1', + }) + expect(connectUrl.origin).toBe('https://e2b.beta.devinenterprise.com') + + const attempt = readDevinOAuthAttempt(attemptCookie) + if (!attempt) throw new Error('expected signed OAuth attempt') + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'scoped-token', + api_base_url: 'https://e2b.beta.devinenterprise.com', + outpost_pool_id: 'pool-1', + }) + ) + vi.stubGlobal('fetch', fetchMock) + await exchangeDevinConnectionCode('one-time-code', attempt) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.devin.ai/outposts/connection-token', + expect.anything() + ) + }) + it('treats invalid_grant as terminal without exposing provider details', async () => { const { attemptCookie } = createDevinOAuthAttempt({ operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', returnOrigin: 'http://localhost:3000', - sandboxId: 'sandbox-1', teamId: 'team-1', teamSlug: 'test-team', userId: 'user-1', @@ -132,7 +160,6 @@ describe('Devin partner OAuth boundary', () => { const { attemptCookie } = createDevinOAuthAttempt({ operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', returnOrigin: 'http://localhost:3000', - sandboxId: 'sandbox-1', teamId: 'team-1', teamSlug: 'test-team', userId: 'user-1', @@ -158,6 +185,16 @@ describe('Devin partner OAuth boundary', () => { expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) }) + it('fails closed for an unsafe connect-page override', () => { + process.env.DEVIN_OUTPOSTS_CONNECT_URL = + 'https://attacker.example/outposts/connect' + expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) + + process.env.DEVIN_OUTPOSTS_CONNECT_URL = + 'https://e2b.beta.devinenterprise.com/chat' + expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) + }) + it('requires the callback to use the dashboard hostname', () => { process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'https://oauth.example.com/callback' diff --git a/tests/unit/devin-outposts-routes.test.ts b/tests/unit/devin-outposts-routes.test.ts index 54a977f21..173459abf 100644 --- a/tests/unit/devin-outposts-routes.test.ts +++ b/tests/unit/devin-outposts-routes.test.ts @@ -3,18 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ createAttempt: vi.fn(), - claimWorker: vi.fn(), - cleanupWorker: vi.fn(), exchangeCode: vi.fn(), + findStartedWorker: vi.fn(), getAuthContext: vi.fn(), getConnectUrl: vi.fn(), getTeamIdFromSlug: vi.fn(), - hasPersistedConnection: vi.fn(), - isWorkerAvailable: vi.fn(), - persistConnection: vi.fn(), - prepareWorker: vi.fn(), + launchWorker: vi.fn(), readAttempt: vi.fn(), - startPersistedWorker: vi.fn(), })) vi.mock('@/core/server/auth', () => ({ @@ -58,20 +53,16 @@ vi.mock('@/core/modules/devin-outposts/worker.server', () => { } return { - cleanupPreparedDevinWorker: mocks.cleanupWorker, - claimPreparedDevinWorker: mocks.claimWorker, DevinWorkerLaunchError, - hasPersistedDevinConnection: mocks.hasPersistedConnection, - isPreparedDevinWorkerAvailable: mocks.isWorkerAvailable, - persistPreparedDevinConnection: mocks.persistConnection, - prepareDevinWorkerSandbox: mocks.prepareWorker, - startPersistedDevinWorker: mocks.startPersistedWorker, + findStartedDevinWorker: mocks.findStartedWorker, + launchDevinWorker: mocks.launchWorker, } }) import { POST as start } from '@/app/api/connections/devin/start/route' import { GET as callback } from '@/app/callback/route' import { DevinOAuthError } from '@/core/modules/devin-outposts/oauth.server' +import { DevinWorkerLaunchError } from '@/core/modules/devin-outposts/worker.server' const authContext = { accessToken: 'dashboard-token', @@ -82,7 +73,6 @@ const attempt = { nonce: 'nonce', operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', returnOrigin: 'http://localhost:3000', - sandboxId: 'sandbox-1', teamId: 'team-1', teamSlug: 'test-team', userId: 'user-1', @@ -92,19 +82,7 @@ const attempt = { describe('Devin OAuth routes', () => { beforeEach(() => { vi.unstubAllEnvs() - mocks.createAttempt.mockReset() - mocks.claimWorker.mockReset() - mocks.cleanupWorker.mockReset() - mocks.exchangeCode.mockReset() - mocks.getAuthContext.mockReset() - mocks.getConnectUrl.mockReset() - mocks.getTeamIdFromSlug.mockReset() - mocks.hasPersistedConnection.mockReset() - mocks.isWorkerAvailable.mockReset() - mocks.persistConnection.mockReset() - mocks.prepareWorker.mockReset() - mocks.readAttempt.mockReset() - mocks.startPersistedWorker.mockReset() + for (const mock of Object.values(mocks)) mock.mockReset() mocks.getAuthContext.mockResolvedValue(authContext) mocks.getTeamIdFromSlug.mockResolvedValue({ ok: true, data: 'team-1' }) mocks.createAttempt.mockReturnValue({ @@ -115,20 +93,13 @@ describe('Devin OAuth routes', () => { new URL('https://app.devin.ai/outposts/connect?resume=1') ) mocks.readAttempt.mockReturnValue(null) - mocks.claimWorker.mockResolvedValue('claimed') - mocks.cleanupWorker.mockResolvedValue(true) - mocks.hasPersistedConnection.mockResolvedValue(false) - mocks.isWorkerAvailable.mockResolvedValue(true) - mocks.prepareWorker.mockResolvedValue({ sandboxId: 'sandbox-1' }) + mocks.findStartedWorker.mockResolvedValue(null) }) it('requires a Dashboard session before creating OAuth state', async () => { mocks.getAuthContext.mockResolvedValue(null) - const response = await start( - startRequest( - 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team' - ) - ) + + const response = await start(startRequest()) expect(response.headers.get('location')).toBe( 'http://localhost:3000/sign-in?returnTo=%2Fdashboard%2Ftest-team%2Fconnections%2Fdevin' @@ -136,82 +107,52 @@ describe('Devin OAuth routes', () => { expect(mocks.createAttempt).not.toHaveBeenCalled() }) - it('sets HttpOnly state and redirects an authorized user to Devin', async () => { - const response = await start( - startRequest( - 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team' - ) - ) + it('redirects to Devin without touching the worker runtime', async () => { + const response = await start(startRequest()) + expect(response.status).toBe(303) expect(response.headers.get('location')).toBe( 'https://app.devin.ai/outposts/connect?test=1' ) - expect(response.status).toBe(303) expect(response.headers.get('set-cookie')).toContain( 'e2b-devin-oauth=signed-attempt' ) expect(response.headers.get('set-cookie')).toContain('HttpOnly') - expect(response.headers.get('cache-control')).toBe('no-store') - expect(mocks.prepareWorker).toHaveBeenCalledWith( - expect.objectContaining({ teamId: 'team-1', userId: 'user-1' }) - ) expect(mocks.createAttempt).toHaveBeenCalledWith( expect.objectContaining({ - sandboxId: 'sandbox-1', teamId: 'team-1', teamSlug: 'test-team', + userId: 'user-1', }) ) + expect(mocks.createAttempt.mock.calls[0]?.[0]).not.toHaveProperty( + 'sandboxId' + ) + expect(mocks.findStartedWorker).not.toHaveBeenCalled() + expect(mocks.launchWorker).not.toHaveBeenCalled() }) - it('resumes an OAuth attempt without overwriting its state', async () => { + it('resumes an active authorization without creating new state', async () => { mocks.readAttempt.mockReturnValue(attempt) - const response = await start( - startRequest( - 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team' - ) - ) + + const response = await start(startRequest()) expect(response.headers.get('location')).toBe( 'https://app.devin.ai/outposts/connect?resume=1' ) expect(mocks.getConnectUrl).toHaveBeenCalledWith(attempt) - expect(mocks.prepareWorker).not.toHaveBeenCalled() expect(mocks.createAttempt).not.toHaveBeenCalled() }) - it('replaces an attempt whose prepared sandbox no longer exists', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.isWorkerAvailable.mockResolvedValue(false) - - const response = await start( - startRequest( - 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team' - ) - ) - - expect(response.headers.get('location')).toBe( - 'https://app.devin.ai/outposts/connect?test=1' - ) - expect(mocks.prepareWorker).toHaveBeenCalled() - expect(mocks.createAttempt).toHaveBeenCalled() - }) - - it('rejects cross-site OAuth starts before preparing a sandbox', async () => { - const response = await start( - startRequest( - 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team', - 'https://attacker.example' - ) - ) + it('rejects cross-site OAuth starts before creating state', async () => { + const response = await start(startRequest('https://attacker.example')) expect(response.status).toBe(403) - expect(mocks.prepareWorker).not.toHaveBeenCalled() expect(mocks.createAttempt).not.toHaveBeenCalled() + expect(mocks.launchWorker).not.toHaveBeenCalled() }) it('rejects a callback without valid state and clears the cookie', async () => { - mocks.readAttempt.mockReturnValue(null) const response = await callback( new NextRequest('http://localhost:8765/callback?code=one-time-code') ) @@ -226,56 +167,35 @@ describe('Devin OAuth routes', () => { expect(mocks.exchangeCode).not.toHaveBeenCalled() }) - it('preserves secure cookie attributes when clearing production state', async () => { - vi.stubEnv('NODE_ENV', 'production') - const response = await callback( - new NextRequest('https://dashboard.example.com/callback?code=invalid') - ) - - expect(response.headers.get('set-cookie')).toContain('Max-Age=0') - expect(response.headers.get('set-cookie')).toContain('HttpOnly') - expect(response.headers.get('set-cookie')).toContain('Secure') - expect(response.headers.get('set-cookie')).toContain('SameSite=lax') - }) - it('rejects a callback after the Dashboard user changes', async () => { mocks.readAttempt.mockReturnValue(attempt) mocks.getAuthContext.mockResolvedValue({ ...authContext, user: { id: 'user-2' }, }) - const response = await callback( - callbackRequest('http://localhost:8765/callback?code=one-time-code') - ) - expect(response.headers.get('location')).toBe( - 'http://localhost:3000/dashboard/test-team/connections/devin?devinOAuth=session' - ) + const response = await callback(callbackRequest()) + + expect(response.headers.get('location')).toContain('devinOAuth=session') expect(mocks.exchangeCode).not.toHaveBeenCalled() }) - it('rejects a callback when the slug resolves to a different team', async () => { + it('rejects a callback when the team binding changes', async () => { mocks.readAttempt.mockReturnValue(attempt) mocks.getTeamIdFromSlug.mockResolvedValue({ ok: true, data: 'team-2' }) - const response = await callback( - callbackRequest('http://localhost:8765/callback?code=one-time-code') - ) + + const response = await callback(callbackRequest()) expect(response.headers.get('location')).toContain('devinOAuth=access') expect(mocks.exchangeCode).not.toHaveBeenCalled() - expect(mocks.cleanupWorker).toHaveBeenCalledWith({ - accessToken: 'dashboard-token', - sandboxId: 'sandbox-1', - teamId: 'team-1', - }) + expect(mocks.launchWorker).not.toHaveBeenCalled() }) - it('recovers a completed worker without redeeming the code again', async () => { + it('recovers an existing worker before redeeming the code again', async () => { mocks.readAttempt.mockReturnValue(attempt) - mocks.claimWorker.mockResolvedValue('started') - const response = await callback( - callbackRequest('http://localhost:8765/callback?code=already-used') - ) + mocks.findStartedWorker.mockResolvedValue('sandbox-1') + + const response = await callback(callbackRequest('already-used')) expect(response.headers.get('location')).toBe( 'http://localhost:3000/dashboard/test-team/sandboxes/sandbox-1/terminal' @@ -283,152 +203,98 @@ describe('Devin OAuth routes', () => { expect(mocks.exchangeCode).not.toHaveBeenCalled() }) - it('preserves state while another callback owns the operation', async () => { + it('preserves state when worker recovery is unavailable', async () => { mocks.readAttempt.mockReturnValue(attempt) - mocks.claimWorker.mockResolvedValue('busy') + mocks.findStartedWorker.mockRejectedValue(new Error('infra unavailable')) - const response = await callback( - callbackRequest('http://localhost:8765/callback?code=one-time-code') - ) - - expect(response.headers.get('location')).toContain('devinOAuth=in_progress') - expect(response.headers.get('set-cookie')).toBeNull() - expect(mocks.exchangeCode).not.toHaveBeenCalled() - expect(mocks.cleanupWorker).not.toHaveBeenCalled() - }) - - it('preserves state when persisted connection inspection is unavailable', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.hasPersistedConnection.mockRejectedValue( - new Error('envd unavailable') - ) - - const response = await callback( - callbackRequest('http://localhost:8765/callback?code=one-time-code') - ) + const response = await callback(callbackRequest()) expect(response.headers.get('location')).toContain('devinOAuth=launch') expect(response.headers.get('set-cookie')).toBeNull() expect(mocks.exchangeCode).not.toHaveBeenCalled() - expect(mocks.cleanupWorker).not.toHaveBeenCalled() - }) - - it('resumes worker startup from credentials persisted in the sandbox', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.hasPersistedConnection.mockResolvedValue(true) - mocks.startPersistedWorker.mockResolvedValue({ sandboxId: 'sandbox-1' }) - - const response = await callback( - callbackRequest('http://localhost:8765/callback') - ) - - expect(response.headers.get('location')).toContain( - '/sandboxes/sandbox-1/terminal' - ) - expect(mocks.exchangeCode).not.toHaveBeenCalled() - expect(mocks.startPersistedWorker).toHaveBeenCalled() }) - it('distinguishes provider failure from explicit authorization denial', async () => { + it('does not let an uncorrelated denial clear an active attempt', async () => { mocks.readAttempt.mockReturnValue(attempt) - const providerFailure = await callback( - callbackRequest('http://localhost:8765/callback?error=server_error') - ) - expect(providerFailure.headers.get('location')).toContain( - 'devinOAuth=provider' - ) - mocks.readAttempt.mockReturnValue(attempt) - const denial = await callback( - callbackRequest('http://localhost:8765/callback?error=access_denied') - ) - expect(denial.headers.get('location')).toContain('devinOAuth=denied') - expect(denial.headers.get('set-cookie')).toBeNull() - expect(mocks.exchangeCode).not.toHaveBeenCalled() - expect(mocks.cleanupWorker).not.toHaveBeenCalled() - }) - - it('maps a consumed or expired code without launching a worker', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.exchangeCode.mockRejectedValue(new DevinOAuthError('invalid_grant')) const response = await callback( - callbackRequest('http://localhost:8765/callback?code=expired') + callbackRequest(undefined, 'error=access_denied') ) - expect(response.headers.get('location')).toContain('devinOAuth=expired') + expect(response.headers.get('location')).toContain('devinOAuth=denied') expect(response.headers.get('set-cookie')).toBeNull() - expect(mocks.startPersistedWorker).not.toHaveBeenCalled() + expect(mocks.exchangeCode).not.toHaveBeenCalled() }) - it('exchanges the code server-side and redirects to the worker terminal', async () => { + it('creates the worker only after exchanging the Devin code', async () => { mocks.readAttempt.mockReturnValue(attempt) mocks.exchangeCode.mockResolvedValue({ accessToken: 'scoped-token', apiUrl: 'https://api.devin.ai', poolId: 'pool-1', }) - mocks.startPersistedWorker.mockResolvedValue({ sandboxId: 'sandbox-1' }) - const response = await callback( - callbackRequest('http://localhost:8765/callback?code=one-time-code') - ) + mocks.launchWorker.mockResolvedValue({ sandboxId: 'sandbox-1' }) + + const response = await callback(callbackRequest()) expect(mocks.exchangeCode).toHaveBeenCalledWith('one-time-code', attempt) - expect(mocks.persistConnection).toHaveBeenCalledWith( - expect.objectContaining({ - outpostsToken: 'scoped-token', - poolId: 'pool-1', - sandboxId: 'sandbox-1', - teamId: 'team-1', - userId: 'user-1', - }) - ) - expect(mocks.startPersistedWorker).toHaveBeenCalledWith( - expect.objectContaining({ sandboxId: 'sandbox-1' }), - { cleanupOnFailure: false } - ) - expect(response.headers.get('location')).toBe( - 'http://localhost:3000/dashboard/test-team/sandboxes/sandbox-1/terminal' + expect(mocks.launchWorker).toHaveBeenCalledWith({ + accessToken: 'dashboard-token', + apiUrl: 'https://api.devin.ai', + operationId: attempt.operationId, + outpostsToken: 'scoped-token', + poolId: 'pool-1', + teamId: 'team-1', + userId: 'user-1', + }) + expect(mocks.exchangeCode.mock.invocationCallOrder[0]).toBeLessThan( + mocks.launchWorker.mock.invocationCallOrder[0] ?? 0 ) - expect(response.headers.get('set-cookie')).toContain( - 'e2b-devin-oauth=; Path=/; Expires=' + expect(response.headers.get('location')).toContain( + '/sandboxes/sandbox-1/terminal' ) + expect(response.headers.get('set-cookie')).toContain('Max-Age=0') }) - it('recovers when credential persistence completed before its response failed', async () => { + it('clears consumed authorization state when worker creation fails', async () => { mocks.readAttempt.mockReturnValue(attempt) mocks.exchangeCode.mockResolvedValue({ accessToken: 'scoped-token', apiUrl: 'https://api.devin.ai', poolId: 'pool-1', }) - mocks.persistConnection.mockRejectedValue(new Error('response lost')) - mocks.hasPersistedConnection - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true) - mocks.startPersistedWorker.mockResolvedValue({ sandboxId: 'sandbox-1' }) + mocks.launchWorker.mockRejectedValue(new DevinWorkerLaunchError()) - const response = await callback( - callbackRequest('http://localhost:8765/callback?code=one-time-code') - ) + const response = await callback(callbackRequest()) - expect(mocks.exchangeCode).toHaveBeenCalledTimes(1) - expect(mocks.hasPersistedConnection).toHaveBeenCalledTimes(2) - expect(mocks.startPersistedWorker).toHaveBeenCalled() - expect(response.headers.get('location')).toContain( - '/sandboxes/sandbox-1/terminal' - ) + expect(response.headers.get('location')).toContain('devinOAuth=launch') + expect(response.headers.get('set-cookie')).toContain('Max-Age=0') }) -}) -function callbackRequest(url: string) { - return new NextRequest(url, { - headers: { cookie: 'e2b-devin-oauth=signed-attempt' }, + it('treats invalid_grant as restartable without creating a worker', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.exchangeCode.mockRejectedValue(new DevinOAuthError('invalid_grant')) + + const response = await callback(callbackRequest('expired')) + + expect(response.headers.get('location')).toContain('devinOAuth=expired') + expect(response.headers.get('set-cookie')).toContain('Max-Age=0') + expect(mocks.launchWorker).not.toHaveBeenCalled() + + mocks.readAttempt.mockReturnValue(null) + await start(startRequest()) + expect(mocks.createAttempt).toHaveBeenCalledOnce() }) +}) + +function startRequest(origin = 'http://localhost:3000') { + return new NextRequest( + 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team', + { headers: { origin } } + ) } -function startRequest(url: string, origin = 'http://localhost:3000') { - return new NextRequest(url, { - headers: { origin }, - method: 'POST', - }) +function callbackRequest(code = 'one-time-code', query?: string) { + const suffix = query ?? `code=${encodeURIComponent(code)}` + return new NextRequest(`http://localhost:8765/callback?${suffix}`) } diff --git a/tests/unit/devin-outposts-worker.test.ts b/tests/unit/devin-outposts-worker.test.ts index e978d0063..4dd34b318 100644 --- a/tests/unit/devin-outposts-worker.test.ts +++ b/tests/unit/devin-outposts-worker.test.ts @@ -27,9 +27,9 @@ vi.mock('e2b', () => ({ })) import { - claimPreparedDevinWorker, DevinWorkerLaunchError, - hasPersistedDevinConnection, + disconnectDevinWorkers, + findStartedDevinWorker, launchDevinWorker, } from '@/core/modules/devin-outposts/worker.server' @@ -172,6 +172,20 @@ describe('Devin worker launcher', () => { ) }) + it('kills the prepared sandbox when credential persistence fails', async () => { + mocks.runtime = sandboxWithResults([{ exitCode: 1, stdout: '' }]) + + await expect(launchDevinWorker(input)).rejects.toBeInstanceOf( + DevinWorkerLaunchError + ) + expect(mocks.infraDelete).toHaveBeenCalledWith( + '/sandboxes/{sandboxID}', + expect.objectContaining({ + params: { path: { sandboxID: 'new-sbx' } }, + }) + ) + }) + it('reports a prepared sandbox that could not be cleaned up', async () => { mocks.runtime = sandboxWithResults([ { exitCode: 0, stdout: '' }, @@ -185,27 +199,21 @@ describe('Devin worker launcher', () => { }) }) - it('uses an atomic sandbox lock to serialize OAuth callbacks', async () => { - mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'busy\n' }]) - - await expect( - claimPreparedDevinWorker({ ...input, sandboxId: 'new-sbx' }) - ).resolves.toBe('busy') - - const command = mocks.runtime.commands.run.mock.calls[0]?.[0] - expect(command).toContain('mkdir') - expect(command).toContain('callback.lock') - expect(command).toContain('claimed-at') - expect(command).toContain('-gt 120') - }) - - it('renews a worker recovered from its PID marker', async () => { - mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'started\n' }]) - - await expect( - claimPreparedDevinWorker({ ...input, sandboxId: 'new-sbx' }) - ).resolves.toBe('started') + it('finds a started worker for callback response-loss recovery', async () => { + mocks.infraGet.mockResolvedValue( + apiResult(200, [sandboxApiResponse('existing-sbx')]) + ) + mocks.infraPost.mockResolvedValue( + apiResult(200, sandboxApiResponse('existing-sbx')) + ) + mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'running' }]) + await expect(findStartedDevinWorker(input)).resolves.toBe('existing-sbx') + expect(mocks.infraPost).toHaveBeenNthCalledWith( + 1, + '/sandboxes/{sandboxID}/connect', + expect.objectContaining({ body: { timeout: 1800 } }) + ) expect(mocks.infraPost).toHaveBeenNthCalledWith( 2, '/sandboxes/{sandboxID}/connect', @@ -213,27 +221,61 @@ describe('Devin worker launcher', () => { ) }) - it.each([ - ['present', true], - ['missing', false], - ] as const)('reads persisted connection state: %s', async (state, expected) => { - mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: state }]) - - await expect( - hasPersistedDevinConnection({ ...input, sandboxId: 'new-sbx' }) - ).resolves.toBe(expected) + it('does not report an incomplete worker as recovered', async () => { + mocks.infraGet.mockResolvedValue( + apiResult(200, [sandboxApiResponse('existing-sbx')]) + ) + mocks.infraPost.mockResolvedValue( + apiResult(200, sandboxApiResponse('existing-sbx')) + ) + mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'stopped' }]) - const command = mocks.runtime.commands.run.mock.calls[0]?.[0] - expect(command).toContain('then printf present') - expect(command).toContain('else printf missing') + await expect(findStartedDevinWorker(input)).resolves.toBeNull() + expect(mocks.infraPost).toHaveBeenCalledTimes(1) + expect(mocks.infraPost).toHaveBeenCalledWith( + '/sandboxes/{sandboxID}/connect', + expect.objectContaining({ body: { timeout: 1800 } }) + ) }) - it('rejects an invalid persisted connection probe result', async () => { - mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'unknown' }]) - - await expect( - hasPersistedDevinConnection({ ...input, sandboxId: 'new-sbx' }) - ).rejects.toBeInstanceOf(DevinWorkerLaunchError) + it('disconnects every Devin worker owned by the current user', async () => { + mocks.infraGet + .mockResolvedValueOnce( + apiResult(200, [sandboxApiResponse('worker-1')], { + 'X-Next-Token': 'next-page', + }) + ) + .mockResolvedValueOnce(apiResult(200, [sandboxApiResponse('worker-2')])) + + await expect(disconnectDevinWorkers(input)).resolves.toEqual({ count: 2 }) + expect(mocks.infraDelete).toHaveBeenCalledTimes(2) + expect(mocks.infraDelete).toHaveBeenCalledWith( + '/sandboxes/{sandboxID}', + expect.objectContaining({ + params: { path: { sandboxID: 'worker-1' } }, + }) + ) + expect(mocks.infraGet).toHaveBeenNthCalledWith( + 1, + '/v2/sandboxes', + expect.objectContaining({ + params: { + query: expect.objectContaining({ + nextToken: undefined, + state: ['running', 'paused'], + }), + }, + }) + ) + expect(mocks.infraGet).toHaveBeenNthCalledWith( + 2, + '/v2/sandboxes', + expect.objectContaining({ + params: { + query: expect.objectContaining({ nextToken: 'next-page' }), + }, + }) + ) }) }) @@ -251,14 +293,27 @@ function sandboxApiResponse(sandboxId: string) { domain: 'e2b.app', envdAccessToken: 'envd-token', envdVersion: '0.1.0', + metadata: { + devinLaunchOperationId: input.operationId, + source: 'dashboard-devin-outposts', + userId: input.userId, + }, sandboxID: sandboxId, templateID: 'devin-outposts', } } -function apiResult(status: number, data?: unknown) { +function apiResult( + status: number, + data?: unknown, + headers: Record = {} +) { return { data, - response: { ok: status >= 200 && status < 300, status }, + response: { + headers: new Headers(headers), + ok: status >= 200 && status < 300, + status, + }, } } From 571ea39c8e363877085a72052c9a46856ce1ce47 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 13:09:51 -0700 Subject: [PATCH 04/16] Align Devin connection with current dashboard contracts Use the current Ory session secret and team flag boundary, preserve the enterprise OAuth host split, and match worker launch lifetime and payloads to the infra API. Recover ambiguous creates and prevent in-flight UI retries from duplicating launches. --- .env.example | 11 +- src/app/api/connections/devin/start/route.ts | 18 ++- src/app/callback/route.ts | 19 ++- .../[teamSlug]/connections/devin/page.tsx | 6 +- .../[teamSlug]/connections/layout.tsx | 1 - .../modules/devin-outposts/oauth.server.ts | 36 +++++- .../modules/devin-outposts/worker.server.ts | 81 ++++++++++--- src/core/server/actions/utils.ts | 2 + src/core/server/api/routers/connections.ts | 25 +++- .../connections/devin-connection-form.tsx | 111 ++++++++++++------ tests/unit/devin-outposts-oauth.test.ts | 27 ++++- tests/unit/devin-outposts-routes.test.ts | 27 +++++ tests/unit/devin-outposts-worker.test.ts | 63 +++++++++- 13 files changed, 354 insertions(+), 73 deletions(-) diff --git a/.env.example b/.env.example index 729c8ca17..53622b26c 100644 --- a/.env.example +++ b/.env.example @@ -8,11 +8,6 @@ DASHBOARD_API_ADMIN_TOKEN=your_dashboard_api_admin_token ### JWE key for the encrypted e2b_session cookie (carries the Hydra OIDC tokens). ### Generate with `openssl rand -hex 32`. E2B_SESSION_SECRET=your_e2b_session_secret -# Optional Devin partner OAuth callback. Cognition must allowlist the exact URL. -# DEVIN_OUTPOSTS_CALLBACK_URL=http://localhost:8765/callback -# DEVIN_OUTPOSTS_CONNECT_URL=https://app.devin.ai/outposts/connect -# DEVIN_OUTPOSTS_TOKEN_URL=https://api.devin.ai/outposts/connection-token -# DEVIN_OUTPOSTS_TEMPLATE=devin-outposts-worker ### Ory Network configuration ORY_SDK_URL=https://your-project.projects.oryapis.com @@ -45,6 +40,12 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### OPTIONAL SERVER ENVIRONMENT VARIABLES ### ================================= +### Devin Outposts partner OAuth. Cognition must allowlist the exact callback URL. +# DEVIN_OUTPOSTS_CALLBACK_URL=http://localhost:8765/callback +# DEVIN_OUTPOSTS_CONNECT_URL=https://app.devin.ai/outposts/connect +# DEVIN_OUTPOSTS_TOKEN_URL=https://api.devin.ai/outposts/connection-token +# DEVIN_OUTPOSTS_TEMPLATE=devin-outposts-worker + ### Self-hosted Ory admin endpoints (alternative to ORY_PROJECT_API_TOKEN). ### Set both when running self-hosted; leave unset to use Ory Network with the PAT above. # ORY_KRATOS_ADMIN_URL=http://localhost:4434 diff --git a/src/app/api/connections/devin/start/route.ts b/src/app/api/connections/devin/start/route.ts index 88453745f..c504ddfb0 100644 --- a/src/app/api/connections/devin/start/route.ts +++ b/src/app/api/connections/devin/start/route.ts @@ -3,7 +3,7 @@ import 'server-only' import { randomUUID } from 'node:crypto' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { AUTH_URLS, getPublicAppOrigin, PROTECTED_URLS } from '@/configs/urls' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { createDevinOAuthAttempt, getDevinOAuthConnectUrl, @@ -12,13 +12,14 @@ import { isDevinOAuthConfigured, readDevinOAuthAttempt, } from '@/core/modules/devin-outposts/oauth.server' +import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server' import { getAuthContext } from '@/core/server/auth' import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' import { TeamSlugSchema } from '@/core/shared/schemas/team' export async function POST(request: NextRequest) { const teamSlug = request.nextUrl.searchParams.get('teamSlug') ?? '' - const publicOrigin = getPublicAppOrigin(request.nextUrl.origin) + const publicOrigin = request.nextUrl.origin const connectionPath = TeamSlugSchema.safeParse(teamSlug).success ? PROTECTED_URLS.CONNECTION_DEVIN(teamSlug) : PROTECTED_URLS.DASHBOARD @@ -44,6 +45,19 @@ export async function POST(request: NextRequest) { if (!teamIdResult.data) { return redirectToConnection(publicOrigin, connectionPath, 'access') } + const connectionsEnabled = await featureFlags.isEnabled( + 'connectionsEnabled', + { + user: { + id: authContext.user.id, + email: authContext.user.email ?? undefined, + }, + team: { id: teamIdResult.data }, + } + ) + if (!connectionsEnabled) { + return new NextResponse(null, { status: 404 }) + } if (!isDevinOAuthConfigured(publicOrigin)) { return redirectToConnection(publicOrigin, connectionPath, 'config') } diff --git a/src/app/callback/route.ts b/src/app/callback/route.ts index 28debb551..790ab337a 100644 --- a/src/app/callback/route.ts +++ b/src/app/callback/route.ts @@ -2,7 +2,7 @@ import 'server-only' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { BASE_URL, getPublicAppOrigin, PROTECTED_URLS } from '@/configs/urls' +import { BASE_URL, PROTECTED_URLS } from '@/configs/urls' import { DevinOAuthError, exchangeDevinConnectionCode, @@ -15,6 +15,7 @@ import { findStartedDevinWorker, launchDevinWorker, } from '@/core/modules/devin-outposts/worker.server' +import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server' import { getAuthContext } from '@/core/server/auth' import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' import { l } from '@/core/shared/clients/logger/logger' @@ -64,6 +65,20 @@ export async function GET(request: NextRequest) { ) } + const connectionsEnabled = await featureFlags.isEnabled( + 'connectionsEnabled', + { + user: { + id: authContext.user.id, + email: authContext.user.email ?? undefined, + }, + team: { id: teamIdResult.data }, + } + ) + if (!connectionsEnabled) { + return finish(new NextResponse(null, { status: 404 })) + } + const workerInput = { accessToken: authContext.accessToken, operationId: attempt.operationId, @@ -190,5 +205,5 @@ function fallbackCallbackOrigin(request: NextRequest) { if (hostname === 'localhost' || hostname === '127.0.0.1') { return new URL(BASE_URL).origin } - return getPublicAppOrigin(request.nextUrl.origin) + return request.nextUrl.origin } diff --git a/src/app/dashboard/[teamSlug]/connections/devin/page.tsx b/src/app/dashboard/[teamSlug]/connections/devin/page.tsx index dfe08ed37..f8c48fe23 100644 --- a/src/app/dashboard/[teamSlug]/connections/devin/page.tsx +++ b/src/app/dashboard/[teamSlug]/connections/devin/page.tsx @@ -18,8 +18,10 @@ export default async function DevinConnectionPage({ params, searchParams, }: DevinConnectionPageProps) { - const { teamSlug } = await params - const { devinOAuth } = await searchParams + const [{ teamSlug }, { devinOAuth }] = await Promise.all([ + params, + searchParams, + ]) const status = Array.isArray(devinOAuth) ? devinOAuth[0] : devinOAuth return ( diff --git a/src/app/dashboard/[teamSlug]/connections/layout.tsx b/src/app/dashboard/[teamSlug]/connections/layout.tsx index 3ab1ca0a4..fea75d586 100644 --- a/src/app/dashboard/[teamSlug]/connections/layout.tsx +++ b/src/app/dashboard/[teamSlug]/connections/layout.tsx @@ -37,7 +37,6 @@ export default async function ConnectionsLayout({ }, team: { id: teamId.data, - slug: teamSlug, }, } ) diff --git a/src/core/modules/devin-outposts/oauth.server.ts b/src/core/modules/devin-outposts/oauth.server.ts index a5e23fb43..4e665ee70 100644 --- a/src/core/modules/devin-outposts/oauth.server.ts +++ b/src/core/modules/devin-outposts/oauth.server.ts @@ -10,7 +10,7 @@ import { z } from 'zod' import { normalizeDevinApiUrl } from './client.server' const DEFAULT_DEVIN_CONNECT_URL = 'https://app.devin.ai/outposts/connect' -const DEVIN_TOKEN_URL = 'https://api.devin.ai/outposts/connection-token' +const DEFAULT_DEVIN_TOKEN_URL = 'https://api.devin.ai/outposts/connection-token' const OAUTH_ATTEMPT_TTL_SECONDS = 30 * 60 const REQUEST_TIMEOUT_MS = 15_000 @@ -51,6 +51,7 @@ export function isDevinOAuthConfigured(returnOrigin: string) { try { validateCallbackHost(getCallbackUrl(), normalizeReturnOrigin(returnOrigin)) getConnectUrl() + getTokenUrl() getSecret() return true } catch { @@ -140,7 +141,7 @@ export async function exchangeDevinConnectionCode( ): Promise { let response: Response try { - response = await fetch(DEVIN_TOKEN_URL, { + response = await fetch(getTokenUrl(), { body: new URLSearchParams({ code, code_verifier: deriveVerifier(attempt), @@ -198,7 +199,7 @@ function pkceChallenge(verifier: string) { } function getSecret() { - const secret = process.env.AUTH_SECRET + const secret = process.env.E2B_SESSION_SECRET if (!secret) throw new DevinOAuthError('config') return secret } @@ -259,6 +260,35 @@ function getConnectUrl() { return url } +function getTokenUrl() { + const value = process.env.DEVIN_OUTPOSTS_TOKEN_URL || DEFAULT_DEVIN_TOKEN_URL + let url: URL + try { + url = new URL(value) + } catch { + throw new DevinOAuthError('config') + } + const hostname = url.hostname.toLowerCase() + const devinOwnedHost = + hostname === 'devin.ai' || + hostname.endsWith('.devin.ai') || + hostname === 'devinenterprise.com' || + hostname.endsWith('.devinenterprise.com') + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + url.pathname !== '/outposts/connection-token' || + url.search || + url.hash || + !devinOwnedHost + ) { + throw new DevinOAuthError('config') + } + return url.toString() +} + function normalizeReturnOrigin(value: string) { const url = new URL(value) const localHttp = diff --git a/src/core/modules/devin-outposts/worker.server.ts b/src/core/modules/devin-outposts/worker.server.ts index d988c1f71..6db4af7f8 100644 --- a/src/core/modules/devin-outposts/worker.server.ts +++ b/src/core/modules/devin-outposts/worker.server.ts @@ -3,13 +3,15 @@ import 'server-only' import { Sandbox } from 'e2b' import { authHeaders } from '@/configs/api' import type { components as InfraComponents } from '@/contracts/infra-api' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' import { infra } from '@/core/shared/clients/api' import { l } from '@/core/shared/clients/logger/logger' import { normalizeDevinApiUrl } from './client.server' const ACTIVE_WORKER_TIMEOUT_MS = 24 * 60 * 60 * 1000 const API_REQUEST_TIMEOUT_MS = 10_000 -const DEFAULT_DEVIN_TEMPLATE = 'devin-outposts' +const DISCONNECT_CONCURRENCY = 10 +const DEFAULT_DEVIN_TEMPLATE = 'devin-outposts-worker' const PREPARED_SANDBOX_TIMEOUT_MS = 30 * 60 * 1000 const WORKER_START_TIMEOUT_MS = 15_000 @@ -27,10 +29,14 @@ export type LaunchDevinWorkerInput = WorkerIdentity & { } type StartPreparedDevinWorkerInput = LaunchDevinWorkerInput & { + activeTimeoutMs: number sandboxId: string } -type PreparedWorkerIdentity = WorkerIdentity & { sandboxId: string } +type PreparedWorkerIdentity = WorkerIdentity & { + activeTimeoutMs: number + sandboxId: string +} type PreparedDevinWorker = { acceptorId: string @@ -54,23 +60,32 @@ export class DevinWorkerLaunchError extends Error { export async function launchDevinWorker( input: LaunchDevinWorkerInput ): Promise { + const activeTimeoutMs = await getActiveWorkerTimeoutMs(input) const existingSandboxId = await findWorkerSandbox(input) const prepared = existingSandboxId ? { sandboxId: existingSandboxId } : await prepareDevinWorkerSandbox(input) - return startPreparedDevinWorker({ ...input, sandboxId: prepared.sandboxId }) + return startPreparedDevinWorker({ + ...input, + activeTimeoutMs, + sandboxId: prepared.sandboxId, + }) } export async function findStartedDevinWorker(input: WorkerIdentity) { const sandboxId = await findWorkerSandbox(input) if (!sandboxId) return null + const activeTimeoutMs = await getActiveWorkerTimeoutMs(input) const sandbox = await connectWorkerSandbox( { ...input, sandboxId }, PREPARED_SANDBOX_TIMEOUT_MS ) if (!(await sandboxHasStartedWorker(sandbox, input.operationId))) return null - await extendWorkerSandbox({ ...input, sandboxId }, ACTIVE_WORKER_TIMEOUT_MS) + await extendWorkerSandbox( + { ...input, activeTimeoutMs, sandboxId }, + activeTimeoutMs + ) return sandboxId } @@ -79,16 +94,26 @@ export async function disconnectDevinWorkers( ) { const result = await listDisconnectableWorkerSandboxes(input) const sandboxIds = result.map((sandbox) => sandbox.sandboxID) - const deleted = await Promise.all( - sandboxIds.map((sandboxId) => - cleanupPreparedDevinWorker({ - accessToken: input.accessToken, - sandboxId, - teamId: input.teamId, - }) + let failed = false + + for ( + let index = 0; + index < sandboxIds.length; + index += DISCONNECT_CONCURRENCY + ) { + const deleted = await Promise.all( + sandboxIds.slice(index, index + DISCONNECT_CONCURRENCY).map((sandboxId) => + cleanupPreparedDevinWorker({ + accessToken: input.accessToken, + sandboxId, + teamId: input.teamId, + }) + ) ) - ) - if (deleted.some((success) => !success)) throw new DevinWorkerLaunchError() + failed ||= deleted.some((success) => !success) + } + + if (failed) throw new DevinWorkerLaunchError() return { count: sandboxIds.length } } @@ -101,6 +126,15 @@ async function prepareDevinWorkerSandbox( const sandbox = await createWorkerSandbox(input) return { acceptorId, sandboxId: sandbox.sandboxID, started: false } } catch (error) { + try { + const recoveredSandboxId = await findWorkerSandbox(input) + if (recoveredSandboxId) { + return { acceptorId, sandboxId: recoveredSandboxId, started: false } + } + } catch { + // Preserve the original create failure below. + } + l.warn( { key: 'devin:worker_sandbox_prepare_failed', @@ -200,7 +234,7 @@ async function startPersistedDevinWorker( throw new Error('worker_start_failed') } - await extendWorkerSandbox(input, ACTIVE_WORKER_TIMEOUT_MS) + await extendWorkerSandbox(input, input.activeTimeoutMs) return { acceptorId, @@ -346,6 +380,7 @@ async function createWorkerSandbox(input: WorkerIdentity) { const result = await infra.POST('/sandboxes', { body: { autoPause: false, + autoPauseMemory: true, autoResume: { enabled: false }, metadata: launchMetadata(input.operationId, input.userId), secure: true, @@ -361,6 +396,24 @@ async function createWorkerSandbox(input: WorkerIdentity) { return result.data } +async function getActiveWorkerTimeoutMs( + input: Pick +) { + const teamsResult = await createUserTeamsRepository({ + accessToken: input.accessToken, + }).listUserTeams() + if (!teamsResult.ok) throw new DevinWorkerLaunchError() + + const maxLengthHours = teamsResult.data.find( + (team) => team.id === input.teamId + )?.limits.maxLengthHours + if (!maxLengthHours || maxLengthHours <= 0) { + throw new DevinWorkerLaunchError() + } + + return Math.min(ACTIVE_WORKER_TIMEOUT_MS, maxLengthHours * 60 * 60 * 1000) +} + async function connectWorkerSandbox( input: Pick & { sandboxId: string diff --git a/src/core/server/actions/utils.ts b/src/core/server/actions/utils.ts index a5cb0df5a..12585e1ae 100644 --- a/src/core/server/actions/utils.ts +++ b/src/core/server/actions/utils.ts @@ -1,5 +1,7 @@ import { getPublicErrorMessage } from '@/core/shared/errors' +export { sanitizeClientInput } from '@/core/shared/observability/sanitize-input' + type ActionErrorOptions = { cause?: unknown expected?: boolean diff --git a/src/core/server/api/routers/connections.ts b/src/core/server/api/routers/connections.ts index 58a27c304..47f81fb7a 100644 --- a/src/core/server/api/routers/connections.ts +++ b/src/core/server/api/routers/connections.ts @@ -10,11 +10,30 @@ import { disconnectDevinWorkers, launchDevinWorker, } from '@/core/modules/devin-outposts/worker.server' +import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' +const connectionsProcedure = protectedTeamProcedure.use( + async ({ ctx, next }) => { + const enabled = await featureFlags.isEnabled('connectionsEnabled', { + user: { + id: ctx.session.user.id, + email: ctx.session.user.email ?? undefined, + }, + team: { id: ctx.teamId }, + }) + + if (!enabled) { + throw new TRPCError({ code: 'NOT_FOUND' }) + } + + return next() + } +) + export const connectionsRouter = createTRPCRouter({ - disconnectDevinWorkers: protectedTeamProcedure + disconnectDevinWorkers: connectionsProcedure .input(z.object({ confirm: z.literal(true) })) .mutation(async ({ ctx }) => { try { @@ -31,7 +50,7 @@ export const connectionsRouter = createTRPCRouter({ } }), - discoverDevin: protectedTeamProcedure + discoverDevin: connectionsProcedure .input( z.object({ apiKey: z.string().trim().min(1).max(4096), @@ -60,7 +79,7 @@ export const connectionsRouter = createTRPCRouter({ } }), - launchDevinWorker: protectedTeamProcedure + launchDevinWorker: connectionsProcedure .input( z.object({ apiUrl: z.string().trim().min(1).max(512), diff --git a/src/features/dashboard/connections/devin-connection-form.tsx b/src/features/dashboard/connections/devin-connection-form.tsx index bf7361b05..9ddfc26d3 100644 --- a/src/features/dashboard/connections/devin-connection-form.tsx +++ b/src/features/dashboard/connections/devin-connection-form.tsx @@ -1,7 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' -import { useRef, useState } from 'react' +import { useReducer, useRef, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { defaultErrorToast, @@ -30,6 +30,28 @@ import { type Organization = { id: string; name: string } type Pool = { id: string; name: string; platform: string } +type ManualConnectionState = { + accountChecked: boolean + apiKey: string + discoverPending: boolean + launchPending: boolean + organizations: Organization[] + outpostsToken: string + poolId: string + pools: Pool[] +} + +const initialManualConnectionState: ManualConnectionState = { + accountChecked: false, + apiKey: '', + discoverPending: false, + launchPending: false, + organizations: [], + outpostsToken: '', + poolId: '', + pools: [], +} + type DevinConnectionFormProps = { oauthEnabled: boolean oauthMessage?: string @@ -187,14 +209,23 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { const router = useRouter() const trpcClient = useTRPCClient() const apiUrlRef = useRef(null) - const [apiKey, setApiKey] = useState('') - const [organizations, setOrganizations] = useState([]) - const [pools, setPools] = useState([]) - const [poolId, setPoolId] = useState('') - const [outpostsToken, setOutpostsToken] = useState('') - const [accountChecked, setAccountChecked] = useState(false) - const [discoverPending, setDiscoverPending] = useState(false) - const [launchPending, setLaunchPending] = useState(false) + const [state, updateState] = useReducer( + ( + current: ManualConnectionState, + update: Partial + ) => ({ ...current, ...update }), + initialManualConnectionState + ) + const { + accountChecked, + apiKey, + discoverPending, + launchPending, + organizations, + outpostsToken, + poolId, + pools, + } = state const launchOperationId = useRef(null) const launchDisabledReason = (() => { @@ -205,29 +236,32 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { })() function resetDiscovery() { - setAccountChecked(false) - setOrganizations([]) - setPools([]) - setPoolId('') - setOutpostsToken('') + updateState({ + accountChecked: false, + organizations: [], + outpostsToken: '', + poolId: '', + pools: [], + }) } async function checkAccount(event: React.FormEvent) { event.preventDefault() - if (!apiKey.trim() || discoverPending) return + if (!apiKey.trim() || discoverPending || launchPending) return const submittedApiKey = apiKey - setApiKey('') - setDiscoverPending(true) + updateState({ apiKey: '', discoverPending: true }) try { const data = await trpcClient.connections.discoverDevin.mutate({ apiKey: submittedApiKey, apiUrl: apiUrlRef.current?.value || 'https://api.devin.ai', teamSlug, }) - setOrganizations(data.organizations) - setPools(data.pools) - setPoolId(data.pools.length === 1 ? data.pools[0]?.id || '' : '') - setAccountChecked(true) + updateState({ + accountChecked: true, + organizations: data.organizations, + poolId: data.pools.length === 1 ? data.pools[0]?.id || '' : '', + pools: data.pools, + }) toast(defaultSuccessToast('Devin account checked.')) } catch (error) { resetDiscovery() @@ -239,15 +273,14 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { ) ) } - setDiscoverPending(false) + updateState({ discoverPending: false }) } async function startWorker(event: React.FormEvent) { event.preventDefault() if (launchDisabledReason || launchPending) return const submittedToken = outpostsToken - setOutpostsToken('') - setLaunchPending(true) + updateState({ launchPending: true, outpostsToken: '' }) if (!launchOperationId.current) { launchOperationId.current = crypto.randomUUID() } @@ -277,7 +310,7 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { ) ) } - setLaunchPending(false) + updateState({ launchPending: false }) } return ( @@ -300,12 +333,18 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { { - setApiKey(event.target.value) - resetDiscovery() + updateState({ + accountChecked: false, + apiKey: event.target.value, + organizations: [], + outpostsToken: '', + poolId: '', + pools: [], + }) }} placeholder="cog_..." /> @@ -320,7 +359,7 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { setOutpostsToken(event.target.value)} + onChange={(event) => + updateState({ outpostsToken: event.target.value }) + } placeholder="Token with the outposts machine scope" /> diff --git a/tests/unit/devin-outposts-oauth.test.ts b/tests/unit/devin-outposts-oauth.test.ts index 2331356fd..094f306d2 100644 --- a/tests/unit/devin-outposts-oauth.test.ts +++ b/tests/unit/devin-outposts-oauth.test.ts @@ -9,24 +9,27 @@ import { readDevinOAuthAttempt, } from '@/core/modules/devin-outposts/oauth.server' -const ORIGINAL_AUTH_SECRET = process.env.AUTH_SECRET +const ORIGINAL_SESSION_SECRET = process.env.E2B_SESSION_SECRET const ORIGINAL_CALLBACK_URL = process.env.DEVIN_OUTPOSTS_CALLBACK_URL const ORIGINAL_CONNECT_URL = process.env.DEVIN_OUTPOSTS_CONNECT_URL +const ORIGINAL_TOKEN_URL = process.env.DEVIN_OUTPOSTS_TOKEN_URL describe('Devin partner OAuth boundary', () => { beforeEach(() => { - process.env.AUTH_SECRET = 'test-auth-secret-with-enough-entropy' + process.env.E2B_SESSION_SECRET = 'test-auth-secret-with-enough-entropy' process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'http://localhost:8765/callback' delete process.env.DEVIN_OUTPOSTS_CONNECT_URL + delete process.env.DEVIN_OUTPOSTS_TOKEN_URL }) afterEach(() => { vi.useRealTimers() vi.unstubAllGlobals() vi.unstubAllEnvs() - restoreEnv('AUTH_SECRET', ORIGINAL_AUTH_SECRET) + restoreEnv('E2B_SESSION_SECRET', ORIGINAL_SESSION_SECRET) restoreEnv('DEVIN_OUTPOSTS_CALLBACK_URL', ORIGINAL_CALLBACK_URL) restoreEnv('DEVIN_OUTPOSTS_CONNECT_URL', ORIGINAL_CONNECT_URL) + restoreEnv('DEVIN_OUTPOSTS_TOKEN_URL', ORIGINAL_TOKEN_URL) }) it('creates signed state and a matching S256 challenge', async () => { @@ -93,9 +96,11 @@ describe('Devin partner OAuth boundary', () => { expect(readDevinOAuthAttempt(attemptCookie)).toBeNull() }) - it('supports a Devin enterprise connect host without changing token exchange', async () => { + it('supports Devin enterprise connect and token hosts', async () => { process.env.DEVIN_OUTPOSTS_CONNECT_URL = 'https://e2b.beta.devinenterprise.com/outposts/connect' + process.env.DEVIN_OUTPOSTS_TOKEN_URL = + 'https://api.beta.devinenterprise.com/outposts/connection-token' const { attemptCookie, connectUrl } = createDevinOAuthAttempt({ operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', returnOrigin: 'http://localhost:3000', @@ -117,7 +122,7 @@ describe('Devin partner OAuth boundary', () => { vi.stubGlobal('fetch', fetchMock) await exchangeDevinConnectionCode('one-time-code', attempt) expect(fetchMock).toHaveBeenCalledWith( - 'https://api.devin.ai/outposts/connection-token', + 'https://api.beta.devinenterprise.com/outposts/connection-token', expect.anything() ) }) @@ -181,7 +186,7 @@ describe('Devin partner OAuth boundary', () => { expect(isDevinOAuthConfigured('https://dashboard.example.com')).toBe(false) process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'http://localhost:8765/callback' - delete process.env.AUTH_SECRET + delete process.env.E2B_SESSION_SECRET expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) }) @@ -195,6 +200,16 @@ describe('Devin partner OAuth boundary', () => { expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) }) + it('fails closed for an unsafe token endpoint override', () => { + process.env.DEVIN_OUTPOSTS_TOKEN_URL = + 'https://attacker.example/outposts/connection-token' + expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) + + process.env.DEVIN_OUTPOSTS_TOKEN_URL = + 'https://api.beta.devinenterprise.com/admin/token' + expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) + }) + it('requires the callback to use the dashboard hostname', () => { process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'https://oauth.example.com/callback' diff --git a/tests/unit/devin-outposts-routes.test.ts b/tests/unit/devin-outposts-routes.test.ts index 173459abf..049148e4f 100644 --- a/tests/unit/devin-outposts-routes.test.ts +++ b/tests/unit/devin-outposts-routes.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ getAuthContext: vi.fn(), getConnectUrl: vi.fn(), getTeamIdFromSlug: vi.fn(), + isFeatureEnabled: vi.fn(), launchWorker: vi.fn(), readAttempt: vi.fn(), })) @@ -20,6 +21,10 @@ vi.mock('@/core/server/functions/team/get-team-id-from-slug', () => ({ getTeamIdFromSlug: mocks.getTeamIdFromSlug, })) +vi.mock('@/core/modules/feature-flags/feature-flags.server', () => ({ + featureFlags: { isEnabled: mocks.isFeatureEnabled }, +})) + vi.mock('@/core/modules/devin-outposts/oauth.server', () => { class DevinOAuthError extends Error { constructor(readonly kind: string) { @@ -85,6 +90,7 @@ describe('Devin OAuth routes', () => { for (const mock of Object.values(mocks)) mock.mockReset() mocks.getAuthContext.mockResolvedValue(authContext) mocks.getTeamIdFromSlug.mockResolvedValue({ ok: true, data: 'team-1' }) + mocks.isFeatureEnabled.mockResolvedValue(true) mocks.createAttempt.mockReturnValue({ attemptCookie: 'signed-attempt', connectUrl: new URL('https://app.devin.ai/outposts/connect?test=1'), @@ -152,6 +158,15 @@ describe('Devin OAuth routes', () => { expect(mocks.launchWorker).not.toHaveBeenCalled() }) + it('does not start OAuth when Connections is disabled for the team', async () => { + mocks.isFeatureEnabled.mockResolvedValue(false) + + const response = await start(startRequest()) + + expect(response.status).toBe(404) + expect(mocks.createAttempt).not.toHaveBeenCalled() + }) + it('rejects a callback without valid state and clears the cookie', async () => { const response = await callback( new NextRequest('http://localhost:8765/callback?code=one-time-code') @@ -191,6 +206,18 @@ describe('Devin OAuth routes', () => { expect(mocks.launchWorker).not.toHaveBeenCalled() }) + it('does not finish OAuth when Connections is disabled for the team', async () => { + mocks.readAttempt.mockReturnValue(attempt) + mocks.isFeatureEnabled.mockResolvedValue(false) + + const response = await callback(callbackRequest()) + + expect(response.status).toBe(404) + expect(response.headers.get('set-cookie')).toContain('Max-Age=0') + expect(mocks.exchangeCode).not.toHaveBeenCalled() + expect(mocks.launchWorker).not.toHaveBeenCalled() + }) + it('recovers an existing worker before redeeming the code again', async () => { mocks.readAttempt.mockReturnValue(attempt) mocks.findStartedWorker.mockResolvedValue('sandbox-1') diff --git a/tests/unit/devin-outposts-worker.test.ts b/tests/unit/devin-outposts-worker.test.ts index 4dd34b318..0b08b9294 100644 --- a/tests/unit/devin-outposts-worker.test.ts +++ b/tests/unit/devin-outposts-worker.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ infraDelete: vi.fn(), infraGet: vi.fn(), infraPost: vi.fn(), + listUserTeams: vi.fn(), runtime: undefined as ReturnType | undefined, })) @@ -15,6 +16,12 @@ vi.mock('@/core/shared/clients/api', () => ({ }, })) +vi.mock('@/core/modules/teams/user-teams-repository.server', () => ({ + createUserTeamsRepository: () => ({ + listUserTeams: mocks.listUserTeams, + }), +})) + vi.mock('e2b', () => ({ Sandbox: class { commands: ReturnType @@ -48,6 +55,7 @@ describe('Devin worker launcher', () => { mocks.infraDelete.mockReset() mocks.infraGet.mockReset() mocks.infraPost.mockReset() + mocks.listUserTeams.mockReset() mocks.runtime = undefined mocks.infraPost.mockImplementation((path: string) => { if (path === '/sandboxes') { @@ -60,6 +68,10 @@ describe('Devin worker launcher', () => { }) mocks.infraDelete.mockResolvedValue(apiResult(204)) mocks.infraGet.mockResolvedValue(apiResult(200, [])) + mocks.listUserTeams.mockResolvedValue({ + ok: true, + data: [{ id: input.teamId, limits: { maxLengthHours: 24 } }], + }) }) it('starts the worker through the bearer-authenticated sandbox API', async () => { @@ -82,6 +94,7 @@ describe('Devin worker launcher', () => { expect.objectContaining({ body: expect.objectContaining({ autoPause: false, + autoPauseMemory: true, autoResume: { enabled: false }, timeout: 1800, }), @@ -101,6 +114,26 @@ describe('Devin worker launcher', () => { ) }) + it('caps the worker lifetime at the team sandbox limit', async () => { + mocks.listUserTeams.mockResolvedValue({ + ok: true, + data: [{ id: input.teamId, limits: { maxLengthHours: 1 } }], + }) + mocks.runtime = sandboxWithResults([ + { exitCode: 0, stdout: '' }, + { exitCode: 0, stdout: 'stopped' }, + { exitCode: 0, stdout: '4242' }, + ]) + + await launchDevinWorker(input) + + expect(mocks.infraPost).toHaveBeenNthCalledWith( + 4, + '/sandboxes/{sandboxID}/connect', + expect.objectContaining({ body: { timeout: 3600 } }) + ) + }) + it('reuses a worker that is already marked as running', async () => { mocks.infraGet.mockResolvedValue( apiResult(200, [sandboxApiResponse('existing-sbx')]) @@ -128,6 +161,34 @@ describe('Devin worker launcher', () => { ) }) + it('recovers a sandbox created before the create response was lost', async () => { + mocks.infraGet + .mockResolvedValueOnce(apiResult(200, [])) + .mockResolvedValueOnce( + apiResult(200, [sandboxApiResponse('recovered-sbx')]) + ) + mocks.infraPost.mockImplementation((path: string) => { + if (path === '/sandboxes') { + return Promise.reject(new Error('create response timed out')) + } + if (path === '/sandboxes/{sandboxID}/connect') { + return apiResult(200, sandboxApiResponse('recovered-sbx')) + } + throw new Error(`unexpected path ${path}`) + }) + mocks.runtime = sandboxWithResults([ + { exitCode: 0, stdout: '' }, + { exitCode: 0, stdout: 'stopped' }, + { exitCode: 0, stdout: '4242' }, + ]) + + await expect(launchDevinWorker(input)).resolves.toMatchObject({ + reused: false, + sandboxId: 'recovered-sbx', + }) + expect(mocks.infraGet).toHaveBeenCalledTimes(2) + }) + it('keeps the scoped credential out of metadata and command text', async () => { const sandbox = sandboxWithResults([ { exitCode: 0, stdout: '' }, @@ -299,7 +360,7 @@ function sandboxApiResponse(sandboxId: string) { userId: input.userId, }, sandboxID: sandboxId, - templateID: 'devin-outposts', + templateID: 'devin-outposts-worker', } } From 1ae82a83e2f48b45739c23cd3e58fe4655cffffb Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 15:10:33 -0700 Subject: [PATCH 05/16] Derive Devin callback routes from dashboard origin Use the canonical dashboard origin for provider redirects while preserving the localhost callback proxy. Give the callback enough runtime for worker startup and keep provider routing server-side. --- .env.example | 5 ++- src/app/api/connections/devin/start/route.ts | 4 +- src/app/callback/route.ts | 2 +- src/app/devin/outpost-callback/route.ts | 8 ++++ .../modules/devin-outposts/oauth.server.ts | 41 +++++-------------- .../connections/devin-connection-form.tsx | 39 +++++------------- src/lib/env.ts | 1 - tests/unit/devin-outposts-oauth.test.ts | 25 +++++------ tests/unit/devin-outposts-routes.test.ts | 3 +- 9 files changed, 50 insertions(+), 78 deletions(-) create mode 100644 src/app/devin/outpost-callback/route.ts diff --git a/.env.example b/.env.example index 53622b26c..d1128538c 100644 --- a/.env.example +++ b/.env.example @@ -40,8 +40,9 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### OPTIONAL SERVER ENVIRONMENT VARIABLES ### ================================= -### Devin Outposts partner OAuth. Cognition must allowlist the exact callback URL. -# DEVIN_OUTPOSTS_CALLBACK_URL=http://localhost:8765/callback +### Devin Outposts partner OAuth. Cognition must allowlist +### /devin/outpost-callback. +### Local development uses http://localhost:8765/callback. # DEVIN_OUTPOSTS_CONNECT_URL=https://app.devin.ai/outposts/connect # DEVIN_OUTPOSTS_TOKEN_URL=https://api.devin.ai/outposts/connection-token # DEVIN_OUTPOSTS_TEMPLATE=devin-outposts-worker diff --git a/src/app/api/connections/devin/start/route.ts b/src/app/api/connections/devin/start/route.ts index c504ddfb0..165e4c328 100644 --- a/src/app/api/connections/devin/start/route.ts +++ b/src/app/api/connections/devin/start/route.ts @@ -3,7 +3,7 @@ import 'server-only' import { randomUUID } from 'node:crypto' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { AUTH_URLS, BASE_URL, PROTECTED_URLS } from '@/configs/urls' import { createDevinOAuthAttempt, getDevinOAuthConnectUrl, @@ -19,7 +19,7 @@ import { TeamSlugSchema } from '@/core/shared/schemas/team' export async function POST(request: NextRequest) { const teamSlug = request.nextUrl.searchParams.get('teamSlug') ?? '' - const publicOrigin = request.nextUrl.origin + const publicOrigin = BASE_URL const connectionPath = TeamSlugSchema.safeParse(teamSlug).success ? PROTECTED_URLS.CONNECTION_DEVIN(teamSlug) : PROTECTED_URLS.DASHBOARD diff --git a/src/app/callback/route.ts b/src/app/callback/route.ts index 790ab337a..0f319c5f5 100644 --- a/src/app/callback/route.ts +++ b/src/app/callback/route.ts @@ -20,7 +20,7 @@ import { getAuthContext } from '@/core/server/auth' import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' import { l } from '@/core/shared/clients/logger/logger' -export const maxDuration = 60 +export const maxDuration = 300 export async function GET(request: NextRequest) { const fallbackOrigin = fallbackCallbackOrigin(request) diff --git a/src/app/devin/outpost-callback/route.ts b/src/app/devin/outpost-callback/route.ts new file mode 100644 index 000000000..3a8ef7ad8 --- /dev/null +++ b/src/app/devin/outpost-callback/route.ts @@ -0,0 +1,8 @@ +import type { NextRequest } from 'next/server' +import { GET as callback } from '@/app/callback/route' + +export const maxDuration = 300 + +export function GET(request: NextRequest) { + return callback(request) +} diff --git a/src/core/modules/devin-outposts/oauth.server.ts b/src/core/modules/devin-outposts/oauth.server.ts index 4e665ee70..f51f3cbab 100644 --- a/src/core/modules/devin-outposts/oauth.server.ts +++ b/src/core/modules/devin-outposts/oauth.server.ts @@ -49,7 +49,7 @@ export class DevinOAuthError extends Error { export function isDevinOAuthConfigured(returnOrigin: string) { try { - validateCallbackHost(getCallbackUrl(), normalizeReturnOrigin(returnOrigin)) + normalizeReturnOrigin(returnOrigin) getConnectUrl() getTokenUrl() getSecret() @@ -84,8 +84,7 @@ export function createDevinOAuthAttempt(input: { export function getDevinOAuthConnectUrl(attempt: DevinOAuthAttempt) { const verifier = deriveVerifier(attempt) - const callbackUrl = getCallbackUrl() - validateCallbackHost(callbackUrl, attempt.returnOrigin) + const callbackUrl = getCallbackUrl(attempt.returnOrigin) const connectUrl = getConnectUrl() connectUrl.searchParams.set('callback_url', callbackUrl) connectUrl.searchParams.set('code_challenge', pkceChallenge(verifier)) @@ -204,30 +203,18 @@ function getSecret() { return secret } -function getCallbackUrl() { - const value = process.env.DEVIN_OUTPOSTS_CALLBACK_URL - if (!value) throw new DevinOAuthError('config') - - let url: URL - try { - url = new URL(value) - } catch { - throw new DevinOAuthError('config') - } - const localHttp = - url.protocol === 'http:' && - (url.hostname === 'localhost' || url.hostname === '127.0.0.1') +function getCallbackUrl(returnOrigin: string) { + const callbackUrl = new URL(returnOrigin) if ( - (!localHttp && url.protocol !== 'https:') || - url.username || - url.password || - url.search || - url.hash || - url.pathname !== '/callback' + callbackUrl.hostname === 'localhost' || + callbackUrl.hostname === '127.0.0.1' ) { - throw new DevinOAuthError('config') + callbackUrl.port = '8765' + callbackUrl.pathname = '/callback' + } else { + callbackUrl.pathname = '/devin/outpost-callback' } - return url.toString() + return callbackUrl.toString() } function getConnectUrl() { @@ -300,12 +287,6 @@ function normalizeReturnOrigin(value: string) { return url.origin } -function validateCallbackHost(callbackUrl: string, returnOrigin: string) { - if (new URL(callbackUrl).hostname !== new URL(returnOrigin).hostname) { - throw new DevinOAuthError('config') - } -} - async function readJsonRecord(response: Response) { const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' if (!contentType.includes('application/json')) return null diff --git a/src/features/dashboard/connections/devin-connection-form.tsx b/src/features/dashboard/connections/devin-connection-form.tsx index 9ddfc26d3..bf6e1dc52 100644 --- a/src/features/dashboard/connections/devin-connection-form.tsx +++ b/src/features/dashboard/connections/devin-connection-form.tsx @@ -81,21 +81,18 @@ export function DevinConnectionForm({

- + {oauthEnabled ? ( + + ) : null} ) } function DevinOAuthConnection({ - oauthEnabled, oauthMessage, teamSlug, -}: DevinConnectionFormProps) { +}: Omit) { const trpcClient = useTRPCClient() const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false) const [disconnectPending, setDisconnectPending] = useState(false) @@ -153,25 +150,15 @@ function DevinOAuthConnection({ ) : null}
- {oauthEnabled ? ( -
- -
- ) : ( - - )} + - {!oauthEnabled ? ( -

- Partner OAuth is not configured for this deployment. Use the manual - setup below. -

- ) : null} ) } diff --git a/src/lib/env.ts b/src/lib/env.ts index df723f1f5..fd261668f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -5,7 +5,6 @@ export const serverSchema = z.object({ BILLING_API_URL: z.url().optional(), PLAIN_API_KEY: z.string().min(1).optional(), - DEVIN_OUTPOSTS_CALLBACK_URL: z.url().optional(), DEVIN_OUTPOSTS_CONNECT_URL: z.url().optional(), DEVIN_OUTPOSTS_TOKEN_URL: z.url().optional(), DEVIN_OUTPOSTS_TEMPLATE: z.string().min(1).optional(), diff --git a/tests/unit/devin-outposts-oauth.test.ts b/tests/unit/devin-outposts-oauth.test.ts index 094f306d2..f192d8c39 100644 --- a/tests/unit/devin-outposts-oauth.test.ts +++ b/tests/unit/devin-outposts-oauth.test.ts @@ -10,14 +10,12 @@ import { } from '@/core/modules/devin-outposts/oauth.server' const ORIGINAL_SESSION_SECRET = process.env.E2B_SESSION_SECRET -const ORIGINAL_CALLBACK_URL = process.env.DEVIN_OUTPOSTS_CALLBACK_URL const ORIGINAL_CONNECT_URL = process.env.DEVIN_OUTPOSTS_CONNECT_URL const ORIGINAL_TOKEN_URL = process.env.DEVIN_OUTPOSTS_TOKEN_URL describe('Devin partner OAuth boundary', () => { beforeEach(() => { process.env.E2B_SESSION_SECRET = 'test-auth-secret-with-enough-entropy' - process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'http://localhost:8765/callback' delete process.env.DEVIN_OUTPOSTS_CONNECT_URL delete process.env.DEVIN_OUTPOSTS_TOKEN_URL }) @@ -27,7 +25,6 @@ describe('Devin partner OAuth boundary', () => { vi.unstubAllGlobals() vi.unstubAllEnvs() restoreEnv('E2B_SESSION_SECRET', ORIGINAL_SESSION_SECRET) - restoreEnv('DEVIN_OUTPOSTS_CALLBACK_URL', ORIGINAL_CALLBACK_URL) restoreEnv('DEVIN_OUTPOSTS_CONNECT_URL', ORIGINAL_CONNECT_URL) restoreEnv('DEVIN_OUTPOSTS_TOKEN_URL', ORIGINAL_TOKEN_URL) }) @@ -181,11 +178,7 @@ describe('Devin partner OAuth boundary', () => { ).rejects.toMatchObject(new DevinOAuthError('response')) }) - it('fails closed for missing or unsafe callback configuration', () => { - process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'https://attacker.example/cb' - expect(isDevinOAuthConfigured('https://dashboard.example.com')).toBe(false) - - process.env.DEVIN_OUTPOSTS_CALLBACK_URL = 'http://localhost:8765/callback' + it('fails closed without a session secret', () => { delete process.env.E2B_SESSION_SECRET expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) }) @@ -210,10 +203,18 @@ describe('Devin partner OAuth boundary', () => { expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) }) - it('requires the callback to use the dashboard hostname', () => { - process.env.DEVIN_OUTPOSTS_CALLBACK_URL = - 'https://oauth.example.com/callback' - expect(isDevinOAuthConfigured('https://dashboard.example.com')).toBe(false) + it('derives the callback from the dashboard origin', () => { + const { connectUrl } = createDevinOAuthAttempt({ + operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', + returnOrigin: 'https://dashboard.example.com', + teamId: 'team-1', + teamSlug: 'test-team', + userId: 'user-1', + }) + + expect(connectUrl.searchParams.get('callback_url')).toBe( + 'https://dashboard.example.com/devin/outpost-callback' + ) }) it('uses an isolated secure host cookie in production', () => { diff --git a/tests/unit/devin-outposts-routes.test.ts b/tests/unit/devin-outposts-routes.test.ts index 049148e4f..f104b79db 100644 --- a/tests/unit/devin-outposts-routes.test.ts +++ b/tests/unit/devin-outposts-routes.test.ts @@ -65,7 +65,7 @@ vi.mock('@/core/modules/devin-outposts/worker.server', () => { }) import { POST as start } from '@/app/api/connections/devin/start/route' -import { GET as callback } from '@/app/callback/route' +import { GET as callback } from '@/app/devin/outpost-callback/route' import { DevinOAuthError } from '@/core/modules/devin-outposts/oauth.server' import { DevinWorkerLaunchError } from '@/core/modules/devin-outposts/worker.server' @@ -126,6 +126,7 @@ describe('Devin OAuth routes', () => { expect(response.headers.get('set-cookie')).toContain('HttpOnly') expect(mocks.createAttempt).toHaveBeenCalledWith( expect.objectContaining({ + returnOrigin: 'http://localhost:3000', teamId: 'team-1', teamSlug: 'test-team', userId: 'user-1', From 96985f8280b38089127464aaa6b36ce889c68866 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 15:27:55 -0700 Subject: [PATCH 06/16] Remove Devin partner OAuth flow Keep manual scoped-credential setup while the provider flow is unavailable. Remove callback state, routes, and OAuth-only recovery behavior. --- .env.example | 6 +- src/app/api/connections/devin/start/route.ts | 124 ------- src/app/callback/route.ts | 209 ----------- .../[teamSlug]/connections/devin/page.tsx | 43 +-- src/app/devin/outpost-callback/route.ts | 8 - .../modules/devin-outposts/oauth.server.ts | 310 ----------------- .../modules/devin-outposts/worker.server.ts | 17 - .../connections/devin-connection-form.tsx | 91 ++--- src/lib/env.ts | 2 - tests/unit/connections-router.test.ts | 105 ++++++ tests/unit/devin-outposts-oauth.test.ts | 236 ------------- tests/unit/devin-outposts-routes.test.ts | 328 ------------------ tests/unit/devin-outposts-worker.test.ts | 40 --- 13 files changed, 132 insertions(+), 1387 deletions(-) delete mode 100644 src/app/api/connections/devin/start/route.ts delete mode 100644 src/app/callback/route.ts delete mode 100644 src/app/devin/outpost-callback/route.ts delete mode 100644 src/core/modules/devin-outposts/oauth.server.ts create mode 100644 tests/unit/connections-router.test.ts delete mode 100644 tests/unit/devin-outposts-oauth.test.ts delete mode 100644 tests/unit/devin-outposts-routes.test.ts diff --git a/.env.example b/.env.example index d1128538c..8cb74a479 100644 --- a/.env.example +++ b/.env.example @@ -40,11 +40,7 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### OPTIONAL SERVER ENVIRONMENT VARIABLES ### ================================= -### Devin Outposts partner OAuth. Cognition must allowlist -### /devin/outpost-callback. -### Local development uses http://localhost:8765/callback. -# DEVIN_OUTPOSTS_CONNECT_URL=https://app.devin.ai/outposts/connect -# DEVIN_OUTPOSTS_TOKEN_URL=https://api.devin.ai/outposts/connection-token +### Devin Outposts worker template. # DEVIN_OUTPOSTS_TEMPLATE=devin-outposts-worker ### Self-hosted Ory admin endpoints (alternative to ORY_PROJECT_API_TOKEN). diff --git a/src/app/api/connections/devin/start/route.ts b/src/app/api/connections/devin/start/route.ts deleted file mode 100644 index 165e4c328..000000000 --- a/src/app/api/connections/devin/start/route.ts +++ /dev/null @@ -1,124 +0,0 @@ -import 'server-only' - -import { randomUUID } from 'node:crypto' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { AUTH_URLS, BASE_URL, PROTECTED_URLS } from '@/configs/urls' -import { - createDevinOAuthAttempt, - getDevinOAuthConnectUrl, - getDevinOAuthCookieName, - getDevinOAuthCookieOptions, - isDevinOAuthConfigured, - readDevinOAuthAttempt, -} from '@/core/modules/devin-outposts/oauth.server' -import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server' -import { getAuthContext } from '@/core/server/auth' -import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' -import { TeamSlugSchema } from '@/core/shared/schemas/team' - -export async function POST(request: NextRequest) { - const teamSlug = request.nextUrl.searchParams.get('teamSlug') ?? '' - const publicOrigin = BASE_URL - const connectionPath = TeamSlugSchema.safeParse(teamSlug).success - ? PROTECTED_URLS.CONNECTION_DEVIN(teamSlug) - : PROTECTED_URLS.DASHBOARD - const authContext = await getAuthContext() - - if (!hasSameOrigin(request, publicOrigin)) { - return new NextResponse(null, { status: 403 }) - } - - if (!authContext) { - const signInUrl = new URL(AUTH_URLS.SIGN_IN, publicOrigin) - signInUrl.searchParams.set('returnTo', connectionPath) - return NextResponse.redirect(signInUrl) - } - - const teamIdResult = await getTeamIdFromSlug( - teamSlug, - authContext.accessToken - ) - if (!teamIdResult.ok) { - return redirectToConnection(publicOrigin, connectionPath, 'dashboard') - } - if (!teamIdResult.data) { - return redirectToConnection(publicOrigin, connectionPath, 'access') - } - const connectionsEnabled = await featureFlags.isEnabled( - 'connectionsEnabled', - { - user: { - id: authContext.user.id, - email: authContext.user.email ?? undefined, - }, - team: { id: teamIdResult.data }, - } - ) - if (!connectionsEnabled) { - return new NextResponse(null, { status: 404 }) - } - if (!isDevinOAuthConfigured(publicOrigin)) { - return redirectToConnection(publicOrigin, connectionPath, 'config') - } - - const activeAttempt = readDevinOAuthAttempt( - request.cookies.get(getDevinOAuthCookieName())?.value - ) - if (activeAttempt) { - const isSameAttempt = - activeAttempt.returnOrigin === publicOrigin && - activeAttempt.teamId === teamIdResult.data && - activeAttempt.teamSlug === teamSlug && - activeAttempt.userId === authContext.user.id - if (!isSameAttempt) { - const activePath = PROTECTED_URLS.CONNECTION_DEVIN(activeAttempt.teamSlug) - return redirectToConnection(publicOrigin, activePath, 'in_progress') - } - - const response = NextResponse.redirect( - getDevinOAuthConnectUrl(activeAttempt), - 303 - ) - response.headers.set('Cache-Control', 'no-store') - return response - } - - const operationId = randomUUID() - - try { - const { attemptCookie, connectUrl } = createDevinOAuthAttempt({ - operationId, - returnOrigin: publicOrigin, - teamId: teamIdResult.data, - teamSlug, - userId: authContext.user.id, - }) - const response = NextResponse.redirect(connectUrl, 303) - response.cookies.set( - getDevinOAuthCookieName(), - attemptCookie, - getDevinOAuthCookieOptions() - ) - response.headers.set('Cache-Control', 'no-store') - return response - } catch { - return redirectToConnection(publicOrigin, connectionPath, 'config') - } -} - -function redirectToConnection(origin: string, path: string, status: string) { - const url = new URL(path, origin) - url.searchParams.set('devinOAuth', status) - return NextResponse.redirect(url, 303) -} - -function hasSameOrigin(request: NextRequest, publicOrigin: string) { - const origin = request.headers.get('origin') - if (!origin) return false - try { - return new URL(origin).origin === new URL(publicOrigin).origin - } catch { - return false - } -} diff --git a/src/app/callback/route.ts b/src/app/callback/route.ts deleted file mode 100644 index 0f319c5f5..000000000 --- a/src/app/callback/route.ts +++ /dev/null @@ -1,209 +0,0 @@ -import 'server-only' - -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { BASE_URL, PROTECTED_URLS } from '@/configs/urls' -import { - DevinOAuthError, - exchangeDevinConnectionCode, - getDevinOAuthCookieName, - getDevinOAuthCookieOptions, - readDevinOAuthAttempt, -} from '@/core/modules/devin-outposts/oauth.server' -import { - DevinWorkerLaunchError, - findStartedDevinWorker, - launchDevinWorker, -} from '@/core/modules/devin-outposts/worker.server' -import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server' -import { getAuthContext } from '@/core/server/auth' -import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' -import { l } from '@/core/shared/clients/logger/logger' - -export const maxDuration = 300 - -export async function GET(request: NextRequest) { - const fallbackOrigin = fallbackCallbackOrigin(request) - const attempt = readDevinOAuthAttempt( - request.cookies.get(getDevinOAuthCookieName())?.value - ) - const fallbackPath = attempt - ? PROTECTED_URLS.CONNECTION_DEVIN(attempt.teamSlug) - : PROTECTED_URLS.DASHBOARD - - if (!attempt) { - return finish( - connectionRedirect(fallbackOrigin, fallbackPath, 'invalid_state') - ) - } - - const connectionPath = PROTECTED_URLS.CONNECTION_DEVIN(attempt.teamSlug) - const authContext = await getAuthContext() - if (!authContext || authContext.user.id !== attempt.userId) { - return finish( - connectionRedirect(attempt.returnOrigin, connectionPath, 'session') - ) - } - - const teamIdResult = await getTeamIdFromSlug( - attempt.teamSlug, - authContext.accessToken - ) - if (!teamIdResult.ok) { - return finish( - connectionRedirect(attempt.returnOrigin, connectionPath, 'dashboard') - ) - } - if (!teamIdResult.data) { - return finish( - connectionRedirect(attempt.returnOrigin, connectionPath, 'access') - ) - } - if (teamIdResult.data !== attempt.teamId) { - return finish( - connectionRedirect(attempt.returnOrigin, connectionPath, 'access') - ) - } - - const connectionsEnabled = await featureFlags.isEnabled( - 'connectionsEnabled', - { - user: { - id: authContext.user.id, - email: authContext.user.email ?? undefined, - }, - team: { id: teamIdResult.data }, - } - ) - if (!connectionsEnabled) { - return finish(new NextResponse(null, { status: 404 })) - } - - const workerInput = { - accessToken: authContext.accessToken, - operationId: attempt.operationId, - teamId: attempt.teamId, - userId: authContext.user.id, - } - - try { - const existingSandboxId = await findStartedDevinWorker(workerInput) - if (existingSandboxId) { - return finish( - terminalRedirect( - attempt.returnOrigin, - attempt.teamSlug, - existingSandboxId - ) - ) - } - } catch (error) { - l.warn( - { - key: 'devin:oauth_worker_recovery_failed', - context: { - error_name: error instanceof Error ? error.name : 'unknown', - }, - team_id: attempt.teamId, - user_id: authContext.user.id, - }, - '[Devin] Could not inspect an existing OAuth worker' - ) - return preserve( - connectionRedirect(attempt.returnOrigin, connectionPath, 'launch') - ) - } - - const codes = request.nextUrl.searchParams.getAll('code') - const providerErrors = request.nextUrl.searchParams.getAll('error') - const code = codes.length === 1 ? codes[0] : undefined - if (!code || code.length > 4096) { - const explicitlyDenied = - providerErrors.length === 1 && providerErrors[0] === 'access_denied' - return preserve( - connectionRedirect( - attempt.returnOrigin, - connectionPath, - explicitlyDenied ? 'denied' : 'provider' - ) - ) - } - - let codeExchanged = false - try { - const token = await exchangeDevinConnectionCode(code, attempt) - codeExchanged = true - const worker = await launchDevinWorker({ - ...workerInput, - apiUrl: token.apiUrl, - outpostsToken: token.accessToken, - poolId: token.poolId, - }) - return finish( - terminalRedirect(attempt.returnOrigin, attempt.teamSlug, worker.sandboxId) - ) - } catch (error) { - const status = - error instanceof DevinOAuthError && error.kind === 'invalid_grant' - ? 'expired' - : error instanceof DevinWorkerLaunchError - ? 'launch' - : 'provider' - l.warn( - { - key: 'devin:oauth_callback_failed', - context: { kind: status }, - team_id: teamIdResult.data, - user_id: authContext.user.id, - }, - '[Devin] OAuth connection did not complete' - ) - const redirect = connectionRedirect( - attempt.returnOrigin, - connectionPath, - status - ) - return codeExchanged || status === 'expired' - ? finish(redirect) - : preserve(redirect) - } -} - -function terminalRedirect(origin: string, teamSlug: string, sandboxId: string) { - return NextResponse.redirect( - new URL(PROTECTED_URLS.SANDBOX_TERMINAL(teamSlug, sandboxId), origin) - ) -} - -function connectionRedirect(origin: string, path: string, status: string) { - const url = new URL(path, origin) - url.searchParams.set('devinOAuth', status) - return NextResponse.redirect(url) -} - -function finish(response: NextResponse) { - response.cookies.set(getDevinOAuthCookieName(), '', { - ...getDevinOAuthCookieOptions(), - expires: new Date(0), - maxAge: 0, - }) - return secureResponse(response) -} - -function preserve(response: NextResponse) { - return secureResponse(response) -} - -function secureResponse(response: NextResponse) { - response.headers.set('Cache-Control', 'no-store') - response.headers.set('Referrer-Policy', 'no-referrer') - return response -} - -function fallbackCallbackOrigin(request: NextRequest) { - const hostname = request.nextUrl.hostname - if (hostname === 'localhost' || hostname === '127.0.0.1') { - return new URL(BASE_URL).origin - } - return request.nextUrl.origin -} diff --git a/src/app/dashboard/[teamSlug]/connections/devin/page.tsx b/src/app/dashboard/[teamSlug]/connections/devin/page.tsx index f8c48fe23..8c1f7c59f 100644 --- a/src/app/dashboard/[teamSlug]/connections/devin/page.tsx +++ b/src/app/dashboard/[teamSlug]/connections/devin/page.tsx @@ -1,6 +1,4 @@ import type { Metadata } from 'next' -import { BASE_URL } from '@/configs/urls' -import { isDevinOAuthConfigured } from '@/core/modules/devin-outposts/oauth.server' import { DevinConnectionForm } from '@/features/dashboard/connections/devin-connection-form' import { Page } from '@/features/dashboard/layouts/page' @@ -11,53 +9,16 @@ export const metadata: Metadata = { interface DevinConnectionPageProps { params: Promise<{ teamSlug: string }> - searchParams: Promise<{ devinOAuth?: string | string[] }> } export default async function DevinConnectionPage({ params, - searchParams, }: DevinConnectionPageProps) { - const [{ teamSlug }, { devinOAuth }] = await Promise.all([ - params, - searchParams, - ]) - const status = Array.isArray(devinOAuth) ? devinOAuth[0] : devinOAuth + const { teamSlug } = await params return ( - + ) } - -function oauthMessage(status: string) { - switch (status) { - case 'access': - return 'This dashboard session cannot access the selected team.' - case 'config': - return 'Devin partner OAuth is not configured for this dashboard deployment.' - case 'denied': - return 'The Devin connection was not authorized.' - case 'dashboard': - return 'The dashboard could not verify team access. Try again.' - case 'expired': - return 'The Devin authorization expired or was already used. Start the connection again.' - case 'invalid_state': - return 'This Devin connection attempt is missing or expired. Start it again.' - case 'in_progress': - return 'A Devin authorization is already in progress in this browser. Finish that attempt or wait for it to expire.' - case 'launch': - return 'The Devin worker sandbox could not be prepared or started.' - case 'provider': - return 'Devin could not complete the connection. Try again.' - case 'session': - return 'Your dashboard session changed during authorization. Sign in and start again.' - default: - return 'The Devin connection did not complete. Start it again.' - } -} diff --git a/src/app/devin/outpost-callback/route.ts b/src/app/devin/outpost-callback/route.ts deleted file mode 100644 index 3a8ef7ad8..000000000 --- a/src/app/devin/outpost-callback/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { NextRequest } from 'next/server' -import { GET as callback } from '@/app/callback/route' - -export const maxDuration = 300 - -export function GET(request: NextRequest) { - return callback(request) -} diff --git a/src/core/modules/devin-outposts/oauth.server.ts b/src/core/modules/devin-outposts/oauth.server.ts deleted file mode 100644 index f51f3cbab..000000000 --- a/src/core/modules/devin-outposts/oauth.server.ts +++ /dev/null @@ -1,310 +0,0 @@ -import 'server-only' - -import { - createHash, - createHmac, - randomBytes, - timingSafeEqual, -} from 'node:crypto' -import { z } from 'zod' -import { normalizeDevinApiUrl } from './client.server' - -const DEFAULT_DEVIN_CONNECT_URL = 'https://app.devin.ai/outposts/connect' -const DEFAULT_DEVIN_TOKEN_URL = 'https://api.devin.ai/outposts/connection-token' -const OAUTH_ATTEMPT_TTL_SECONDS = 30 * 60 -const REQUEST_TIMEOUT_MS = 15_000 - -const oauthAttemptSchema = z.strictObject({ - expiresAt: z.number().int().positive(), - nonce: z.string().min(32).max(128), - operationId: z.uuid(), - returnOrigin: z.url(), - teamId: z.string().min(1).max(256), - teamSlug: z.string().min(1).max(256), - userId: z.string().min(1).max(256), - version: z.literal(1), -}) - -const tokenResponseSchema = z.looseObject({ - access_token: z.string().min(1).max(8192), - api_base_url: z.string().min(1).max(512), - outpost_pool_id: z.string().min(1).max(256), -}) - -export type DevinOAuthAttempt = z.infer - -export type DevinConnectionToken = { - accessToken: string - apiUrl: string - poolId: string -} - -export class DevinOAuthError extends Error { - constructor( - readonly kind: 'config' | 'invalid_grant' | 'provider' | 'response' - ) { - super(`Devin OAuth failed: ${kind}`) - } -} - -export function isDevinOAuthConfigured(returnOrigin: string) { - try { - normalizeReturnOrigin(returnOrigin) - getConnectUrl() - getTokenUrl() - getSecret() - return true - } catch { - return false - } -} - -export function createDevinOAuthAttempt(input: { - operationId: string - returnOrigin: string - teamId: string - teamSlug: string - userId: string -}) { - const attempt: DevinOAuthAttempt = { - expiresAt: Date.now() + OAUTH_ATTEMPT_TTL_SECONDS * 1000, - nonce: randomBytes(32).toString('base64url'), - operationId: input.operationId, - returnOrigin: normalizeReturnOrigin(input.returnOrigin), - teamId: input.teamId, - teamSlug: input.teamSlug, - userId: input.userId, - version: 1, - } - return { - attemptCookie: signAttempt(attempt), - connectUrl: getDevinOAuthConnectUrl(attempt), - } -} - -export function getDevinOAuthConnectUrl(attempt: DevinOAuthAttempt) { - const verifier = deriveVerifier(attempt) - const callbackUrl = getCallbackUrl(attempt.returnOrigin) - const connectUrl = getConnectUrl() - connectUrl.searchParams.set('callback_url', callbackUrl) - connectUrl.searchParams.set('code_challenge', pkceChallenge(verifier)) - connectUrl.searchParams.set('platform', 'linux') - connectUrl.searchParams.set('pool_name', 'E2B worker') - - return connectUrl -} - -export function readDevinOAuthAttempt( - signedAttempt: string | undefined -): DevinOAuthAttempt | null { - if (!signedAttempt) return null - const [encoded, signature, ...rest] = signedAttempt.split('.') - if (!encoded || !signature || rest.length > 0) return null - - const expected = sign(encoded) - if (!safeEqual(signature, expected)) return null - - try { - const parsed = oauthAttemptSchema.safeParse( - JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) - ) - if (!parsed.success || parsed.data.expiresAt <= Date.now()) return null - return parsed.data - } catch { - return null - } -} - -export function getDevinOAuthCookieName() { - const instancePrefix = process.env.AUTH_COOKIE_PREFIX - ? `${process.env.AUTH_COOKIE_PREFIX}.` - : '' - return process.env.NODE_ENV === 'production' - ? `__Host-${instancePrefix}e2b-devin-oauth` - : `${instancePrefix}e2b-devin-oauth` -} - -export function getDevinOAuthCookieOptions() { - return { - httpOnly: true, - maxAge: OAUTH_ATTEMPT_TTL_SECONDS, - path: '/', - sameSite: 'lax' as const, - secure: process.env.NODE_ENV === 'production', - } -} - -export async function exchangeDevinConnectionCode( - code: string, - attempt: DevinOAuthAttempt -): Promise { - let response: Response - try { - response = await fetch(getTokenUrl(), { - body: new URLSearchParams({ - code, - code_verifier: deriveVerifier(attempt), - grant_type: 'authorization_code', - }), - cache: 'no-store', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - method: 'POST', - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }) - } catch { - throw new DevinOAuthError('provider') - } - - const body = await readJsonRecord(response) - if (!response.ok) { - throw new DevinOAuthError( - response.status === 400 && body?.error === 'invalid_grant' - ? 'invalid_grant' - : 'provider' - ) - } - - const parsed = tokenResponseSchema.safeParse(body) - if (!parsed.success) throw new DevinOAuthError('response') - - return { - accessToken: parsed.data.access_token, - apiUrl: normalizeDevinApiUrl(parsed.data.api_base_url), - poolId: parsed.data.outpost_pool_id, - } -} - -function signAttempt(attempt: DevinOAuthAttempt) { - const encoded = Buffer.from(JSON.stringify(attempt)).toString('base64url') - return `${encoded}.${sign(encoded)}` -} - -function sign(value: string) { - return createHmac('sha256', getSecret()) - .update(`devin-oauth-state:${value}`) - .digest('base64url') -} - -function deriveVerifier(attempt: DevinOAuthAttempt) { - return createHmac('sha256', getSecret()) - .update( - `devin-oauth-pkce:${attempt.nonce}:${attempt.userId}:${attempt.teamId}:${attempt.operationId}:${attempt.expiresAt}` - ) - .digest('base64url') -} - -function pkceChallenge(verifier: string) { - return createHash('sha256').update(verifier).digest('base64url') -} - -function getSecret() { - const secret = process.env.E2B_SESSION_SECRET - if (!secret) throw new DevinOAuthError('config') - return secret -} - -function getCallbackUrl(returnOrigin: string) { - const callbackUrl = new URL(returnOrigin) - if ( - callbackUrl.hostname === 'localhost' || - callbackUrl.hostname === '127.0.0.1' - ) { - callbackUrl.port = '8765' - callbackUrl.pathname = '/callback' - } else { - callbackUrl.pathname = '/devin/outpost-callback' - } - return callbackUrl.toString() -} - -function getConnectUrl() { - const value = - process.env.DEVIN_OUTPOSTS_CONNECT_URL || DEFAULT_DEVIN_CONNECT_URL - let url: URL - try { - url = new URL(value) - } catch { - throw new DevinOAuthError('config') - } - const hostname = url.hostname.toLowerCase() - const devinOwnedHost = - hostname === 'devin.ai' || - hostname.endsWith('.devin.ai') || - hostname === 'devinenterprise.com' || - hostname.endsWith('.devinenterprise.com') - if ( - url.protocol !== 'https:' || - url.username || - url.password || - url.port || - url.pathname !== '/outposts/connect' || - url.search || - url.hash || - !devinOwnedHost - ) { - throw new DevinOAuthError('config') - } - return url -} - -function getTokenUrl() { - const value = process.env.DEVIN_OUTPOSTS_TOKEN_URL || DEFAULT_DEVIN_TOKEN_URL - let url: URL - try { - url = new URL(value) - } catch { - throw new DevinOAuthError('config') - } - const hostname = url.hostname.toLowerCase() - const devinOwnedHost = - hostname === 'devin.ai' || - hostname.endsWith('.devin.ai') || - hostname === 'devinenterprise.com' || - hostname.endsWith('.devinenterprise.com') - if ( - url.protocol !== 'https:' || - url.username || - url.password || - url.port || - url.pathname !== '/outposts/connection-token' || - url.search || - url.hash || - !devinOwnedHost - ) { - throw new DevinOAuthError('config') - } - return url.toString() -} - -function normalizeReturnOrigin(value: string) { - const url = new URL(value) - const localHttp = - url.protocol === 'http:' && - (url.hostname === 'localhost' || url.hostname === '127.0.0.1') - if ((!localHttp && url.protocol !== 'https:') || url.origin !== value) { - throw new DevinOAuthError('config') - } - return url.origin -} - -async function readJsonRecord(response: Response) { - const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' - if (!contentType.includes('application/json')) return null - try { - const value: unknown = await response.json() - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null - } catch { - return null - } -} - -function safeEqual(left: string, right: string) { - const leftBuffer = Buffer.from(left) - const rightBuffer = Buffer.from(right) - return ( - leftBuffer.length === rightBuffer.length && - timingSafeEqual(leftBuffer, rightBuffer) - ) -} diff --git a/src/core/modules/devin-outposts/worker.server.ts b/src/core/modules/devin-outposts/worker.server.ts index 6db4af7f8..9c1432cde 100644 --- a/src/core/modules/devin-outposts/worker.server.ts +++ b/src/core/modules/devin-outposts/worker.server.ts @@ -72,23 +72,6 @@ export async function launchDevinWorker( }) } -export async function findStartedDevinWorker(input: WorkerIdentity) { - const sandboxId = await findWorkerSandbox(input) - if (!sandboxId) return null - - const activeTimeoutMs = await getActiveWorkerTimeoutMs(input) - const sandbox = await connectWorkerSandbox( - { ...input, sandboxId }, - PREPARED_SANDBOX_TIMEOUT_MS - ) - if (!(await sandboxHasStartedWorker(sandbox, input.operationId))) return null - await extendWorkerSandbox( - { ...input, activeTimeoutMs, sandboxId }, - activeTimeoutMs - ) - return sandboxId -} - export async function disconnectDevinWorkers( input: Pick ) { diff --git a/src/features/dashboard/connections/devin-connection-form.tsx b/src/features/dashboard/connections/devin-connection-form.tsx index bf6e1dc52..148b71fae 100644 --- a/src/features/dashboard/connections/devin-connection-form.tsx +++ b/src/features/dashboard/connections/devin-connection-form.tsx @@ -53,16 +53,10 @@ const initialManualConnectionState: ManualConnectionState = { } type DevinConnectionFormProps = { - oauthEnabled: boolean - oauthMessage?: string teamSlug: string } -export function DevinConnectionForm({ - oauthEnabled, - oauthMessage, - teamSlug, -}: DevinConnectionFormProps) { +export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { return (
@@ -81,18 +75,13 @@ export function DevinConnectionForm({

- {oauthEnabled ? ( - - ) : null} +
) } -function DevinOAuthConnection({ - oauthMessage, - teamSlug, -}: Omit) { +function DevinWorkerControls({ teamSlug }: DevinConnectionFormProps) { const trpcClient = useTRPCClient() const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false) const [disconnectPending, setDisconnectPending] = useState(false) @@ -127,61 +116,29 @@ function DevinOAuthConnection({ return (
-
- -
-

Connect with Devin

-

- Authorize E2B in Devin. Devin creates a dedicated pool and scoped - service user. After approval, the dashboard creates the worker - sandbox and injects the scoped credential without putting it in - browser state. -

-
-
- - {oauthMessage ? ( -

- {oauthMessage} -

- ) : null} - -
-
- -
- - Disconnect workers - - - } - /> -
+

Workers

- Disconnecting stops the E2B workers. To revoke Devin access entirely, - delete the generated service user in Devin enterprise settings. + Stop every Devin worker sandbox created for your account in this E2B + team.

+ + Disconnect workers + + + } + />
) } diff --git a/src/lib/env.ts b/src/lib/env.ts index fd261668f..d7c3209bd 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -5,8 +5,6 @@ export const serverSchema = z.object({ BILLING_API_URL: z.url().optional(), PLAIN_API_KEY: z.string().min(1).optional(), - DEVIN_OUTPOSTS_CONNECT_URL: z.url().optional(), - DEVIN_OUTPOSTS_TOKEN_URL: z.url().optional(), DEVIN_OUTPOSTS_TEMPLATE: z.string().min(1).optional(), POSTHOG_API_KEY: z.string().min(1).optional(), diff --git a/tests/unit/connections-router.test.ts b/tests/unit/connections-router.test.ts new file mode 100644 index 000000000..d7e6bdc0f --- /dev/null +++ b/tests/unit/connections-router.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTRPCContext } from '@/core/server/trpc/init' + +const mocks = vi.hoisted(() => ({ + featureEnabled: vi.fn(), + getAuthContext: vi.fn(), + getTeamIdFromSlug: vi.fn(), + launchDevinWorker: vi.fn(), + normalizeDevinApiUrl: vi.fn(), +})) + +vi.mock('@/core/server/auth', () => ({ + getAuthContext: mocks.getAuthContext, +})) + +vi.mock('@/core/server/functions/team/get-team-id-from-slug', () => ({ + getTeamIdFromSlug: mocks.getTeamIdFromSlug, +})) + +vi.mock('@/core/modules/feature-flags/feature-flags.server', () => ({ + featureFlags: { isEnabled: mocks.featureEnabled }, +})) + +vi.mock('@/core/modules/devin-outposts/client.server', () => ({ + DevinConnectionError: class DevinConnectionError extends Error {}, + discoverDevinAccount: vi.fn(), + normalizeDevinApiUrl: mocks.normalizeDevinApiUrl, +})) + +vi.mock('@/core/modules/devin-outposts/worker.server', () => ({ + DevinWorkerLaunchError: class DevinWorkerLaunchError extends Error {}, + disconnectDevinWorkers: vi.fn(), + launchDevinWorker: mocks.launchDevinWorker, +})) + +const { createCallerFactory } = await import('@/core/server/trpc/init') +const { connectionsRouter } = await import( + '@/core/server/api/routers/connections' +) + +const createCaller = createCallerFactory(connectionsRouter) +const input = { + teamSlug: 'customer-team', + apiUrl: 'https://api.devin.ai', + operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', + outpostsToken: 'scoped-outposts-token', + poolId: 'pool-1', +} + +async function caller() { + return createCaller(await createTRPCContext({ headers: new Headers() })) +} + +describe('connectionsRouter.launchDevinWorker', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getAuthContext.mockResolvedValue({ + accessToken: 'dashboard-access-token', + user: { id: 'user-1', email: 'user@example.com' }, + }) + mocks.getTeamIdFromSlug.mockResolvedValue({ + ok: true, + data: 'resolved-team-id', + }) + mocks.featureEnabled.mockResolvedValue(true) + mocks.launchDevinWorker.mockResolvedValue({ + acceptorId: 'acceptor-1', + reused: false, + sandboxId: 'sandbox-1', + workerPid: '123', + }) + }) + + it('returns not found without launching when Connections is disabled', async () => { + mocks.featureEnabled.mockResolvedValue(false) + + await expect( + (await caller()).launchDevinWorker(input) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }) + + expect(mocks.featureEnabled).toHaveBeenCalledWith('connectionsEnabled', { + user: { id: 'user-1', email: 'user@example.com' }, + team: { id: 'resolved-team-id' }, + }) + expect(mocks.launchDevinWorker).not.toHaveBeenCalled() + }) + + it('launches with the server-resolved session and team identity', async () => { + await (await caller()).launchDevinWorker(input) + + expect(mocks.getTeamIdFromSlug).toHaveBeenCalledWith( + 'customer-team', + 'dashboard-access-token' + ) + expect(mocks.launchDevinWorker).toHaveBeenCalledWith({ + accessToken: 'dashboard-access-token', + apiUrl: input.apiUrl, + operationId: input.operationId, + outpostsToken: input.outpostsToken, + poolId: input.poolId, + teamId: 'resolved-team-id', + userId: 'user-1', + }) + }) +}) diff --git a/tests/unit/devin-outposts-oauth.test.ts b/tests/unit/devin-outposts-oauth.test.ts deleted file mode 100644 index f192d8c39..000000000 --- a/tests/unit/devin-outposts-oauth.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createDevinOAuthAttempt, - DevinOAuthError, - exchangeDevinConnectionCode, - getDevinOAuthCookieName, - getDevinOAuthCookieOptions, - isDevinOAuthConfigured, - readDevinOAuthAttempt, -} from '@/core/modules/devin-outposts/oauth.server' - -const ORIGINAL_SESSION_SECRET = process.env.E2B_SESSION_SECRET -const ORIGINAL_CONNECT_URL = process.env.DEVIN_OUTPOSTS_CONNECT_URL -const ORIGINAL_TOKEN_URL = process.env.DEVIN_OUTPOSTS_TOKEN_URL - -describe('Devin partner OAuth boundary', () => { - beforeEach(() => { - process.env.E2B_SESSION_SECRET = 'test-auth-secret-with-enough-entropy' - delete process.env.DEVIN_OUTPOSTS_CONNECT_URL - delete process.env.DEVIN_OUTPOSTS_TOKEN_URL - }) - - afterEach(() => { - vi.useRealTimers() - vi.unstubAllGlobals() - vi.unstubAllEnvs() - restoreEnv('E2B_SESSION_SECRET', ORIGINAL_SESSION_SECRET) - restoreEnv('DEVIN_OUTPOSTS_CONNECT_URL', ORIGINAL_CONNECT_URL) - restoreEnv('DEVIN_OUTPOSTS_TOKEN_URL', ORIGINAL_TOKEN_URL) - }) - - it('creates signed state and a matching S256 challenge', async () => { - const { attemptCookie, connectUrl } = createDevinOAuthAttempt({ - operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', - returnOrigin: 'http://localhost:3000', - teamId: 'team-1', - teamSlug: 'test-team', - userId: 'user-1', - }) - const attempt = readDevinOAuthAttempt(attemptCookie) - expect(attempt).toMatchObject({ - returnOrigin: 'http://localhost:3000', - teamId: 'team-1', - teamSlug: 'test-team', - userId: 'user-1', - }) - expect(connectUrl.origin).toBe('https://app.devin.ai') - expect(connectUrl.pathname).toBe('/outposts/connect') - expect(connectUrl.searchParams.get('callback_url')).toBe( - 'http://localhost:8765/callback' - ) - expect(connectUrl.searchParams.get('platform')).toBe('linux') - expect(connectUrl.searchParams.has('code_challenge_method')).toBe(false) - - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - access_token: 'scoped-token', - api_base_url: 'https://api.devin.ai', - outpost_pool_id: 'pool-1', - }) - ) - vi.stubGlobal('fetch', fetchMock) - if (!attempt) throw new Error('expected signed OAuth attempt') - await exchangeDevinConnectionCode('one-time-code', attempt) - - const request = fetchMock.mock.calls[0]?.[1] as RequestInit - const body = request.body as URLSearchParams - const verifier = body.get('code_verifier') - if (!verifier) throw new Error('expected PKCE verifier') - const challenge = await crypto.subtle.digest( - 'SHA-256', - new TextEncoder().encode(verifier) - ) - expect(Buffer.from(challenge).toString('base64url')).toBe( - connectUrl.searchParams.get('code_challenge') - ) - expect(attemptCookie).not.toContain(verifier) - }) - - it('rejects tampered and expired state', () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-07-13T12:00:00Z')) - const { attemptCookie } = createDevinOAuthAttempt({ - operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', - returnOrigin: 'http://localhost:3000', - teamId: 'team-1', - teamSlug: 'test-team', - userId: 'user-1', - }) - - expect(readDevinOAuthAttempt(`${attemptCookie}x`)).toBeNull() - vi.advanceTimersByTime(30 * 60 * 1000 + 1) - expect(readDevinOAuthAttempt(attemptCookie)).toBeNull() - }) - - it('supports Devin enterprise connect and token hosts', async () => { - process.env.DEVIN_OUTPOSTS_CONNECT_URL = - 'https://e2b.beta.devinenterprise.com/outposts/connect' - process.env.DEVIN_OUTPOSTS_TOKEN_URL = - 'https://api.beta.devinenterprise.com/outposts/connection-token' - const { attemptCookie, connectUrl } = createDevinOAuthAttempt({ - operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', - returnOrigin: 'http://localhost:3000', - teamId: 'team-1', - teamSlug: 'test-team', - userId: 'user-1', - }) - expect(connectUrl.origin).toBe('https://e2b.beta.devinenterprise.com') - - const attempt = readDevinOAuthAttempt(attemptCookie) - if (!attempt) throw new Error('expected signed OAuth attempt') - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - access_token: 'scoped-token', - api_base_url: 'https://e2b.beta.devinenterprise.com', - outpost_pool_id: 'pool-1', - }) - ) - vi.stubGlobal('fetch', fetchMock) - await exchangeDevinConnectionCode('one-time-code', attempt) - expect(fetchMock).toHaveBeenCalledWith( - 'https://api.beta.devinenterprise.com/outposts/connection-token', - expect.anything() - ) - }) - - it('treats invalid_grant as terminal without exposing provider details', async () => { - const { attemptCookie } = createDevinOAuthAttempt({ - operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', - returnOrigin: 'http://localhost:3000', - teamId: 'team-1', - teamSlug: 'test-team', - userId: 'user-1', - }) - const attempt = readDevinOAuthAttempt(attemptCookie) - if (!attempt) throw new Error('expected signed OAuth attempt') - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue( - Response.json( - { - error: 'invalid_grant', - error_description: 'sensitive provider detail', - }, - { status: 400 } - ) - ) - ) - - const error = await exchangeDevinConnectionCode( - 'expired-code', - attempt - ).catch((caught: unknown) => caught) - expect(error).toMatchObject({ kind: 'invalid_grant' }) - expect(error).not.toHaveProperty( - 'message', - expect.stringContaining('sensitive provider detail') - ) - }) - - it('rejects malformed success responses', async () => { - const { attemptCookie } = createDevinOAuthAttempt({ - operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', - returnOrigin: 'http://localhost:3000', - teamId: 'team-1', - teamSlug: 'test-team', - userId: 'user-1', - }) - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue(Response.json({ access_token: 'token' })) - ) - - const attempt = readDevinOAuthAttempt(attemptCookie) - if (!attempt) throw new Error('expected signed OAuth attempt') - await expect( - exchangeDevinConnectionCode('one-time-code', attempt) - ).rejects.toMatchObject(new DevinOAuthError('response')) - }) - - it('fails closed without a session secret', () => { - delete process.env.E2B_SESSION_SECRET - expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) - }) - - it('fails closed for an unsafe connect-page override', () => { - process.env.DEVIN_OUTPOSTS_CONNECT_URL = - 'https://attacker.example/outposts/connect' - expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) - - process.env.DEVIN_OUTPOSTS_CONNECT_URL = - 'https://e2b.beta.devinenterprise.com/chat' - expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) - }) - - it('fails closed for an unsafe token endpoint override', () => { - process.env.DEVIN_OUTPOSTS_TOKEN_URL = - 'https://attacker.example/outposts/connection-token' - expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) - - process.env.DEVIN_OUTPOSTS_TOKEN_URL = - 'https://api.beta.devinenterprise.com/admin/token' - expect(isDevinOAuthConfigured('http://localhost:3000')).toBe(false) - }) - - it('derives the callback from the dashboard origin', () => { - const { connectUrl } = createDevinOAuthAttempt({ - operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', - returnOrigin: 'https://dashboard.example.com', - teamId: 'team-1', - teamSlug: 'test-team', - userId: 'user-1', - }) - - expect(connectUrl.searchParams.get('callback_url')).toBe( - 'https://dashboard.example.com/devin/outpost-callback' - ) - }) - - it('uses an isolated secure host cookie in production', () => { - vi.stubEnv('NODE_ENV', 'production') - vi.stubEnv('AUTH_COOKIE_PREFIX', 'dashboard-a') - expect(getDevinOAuthCookieName()).toBe('__Host-dashboard-a.e2b-devin-oauth') - expect(getDevinOAuthCookieOptions()).toMatchObject({ - httpOnly: true, - path: '/', - sameSite: 'lax', - secure: true, - }) - }) -}) - -function restoreEnv(name: string, value: string | undefined) { - if (value === undefined) delete process.env[name] - else process.env[name] = value -} diff --git a/tests/unit/devin-outposts-routes.test.ts b/tests/unit/devin-outposts-routes.test.ts deleted file mode 100644 index f104b79db..000000000 --- a/tests/unit/devin-outposts-routes.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - createAttempt: vi.fn(), - exchangeCode: vi.fn(), - findStartedWorker: vi.fn(), - getAuthContext: vi.fn(), - getConnectUrl: vi.fn(), - getTeamIdFromSlug: vi.fn(), - isFeatureEnabled: vi.fn(), - launchWorker: vi.fn(), - readAttempt: vi.fn(), -})) - -vi.mock('@/core/server/auth', () => ({ - getAuthContext: mocks.getAuthContext, -})) - -vi.mock('@/core/server/functions/team/get-team-id-from-slug', () => ({ - getTeamIdFromSlug: mocks.getTeamIdFromSlug, -})) - -vi.mock('@/core/modules/feature-flags/feature-flags.server', () => ({ - featureFlags: { isEnabled: mocks.isFeatureEnabled }, -})) - -vi.mock('@/core/modules/devin-outposts/oauth.server', () => { - class DevinOAuthError extends Error { - constructor(readonly kind: string) { - super(`oauth:${kind}`) - } - } - - return { - createDevinOAuthAttempt: mocks.createAttempt, - DevinOAuthError, - exchangeDevinConnectionCode: mocks.exchangeCode, - getDevinOAuthConnectUrl: mocks.getConnectUrl, - getDevinOAuthCookieName: () => 'e2b-devin-oauth', - getDevinOAuthCookieOptions: () => ({ - httpOnly: true, - maxAge: 1800, - path: '/', - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - }), - isDevinOAuthConfigured: () => true, - readDevinOAuthAttempt: mocks.readAttempt, - } -}) - -vi.mock('@/core/modules/devin-outposts/worker.server', () => { - class DevinWorkerLaunchError extends Error { - constructor(readonly orphanedSandboxId?: string) { - super('launch failed') - } - } - - return { - DevinWorkerLaunchError, - findStartedDevinWorker: mocks.findStartedWorker, - launchDevinWorker: mocks.launchWorker, - } -}) - -import { POST as start } from '@/app/api/connections/devin/start/route' -import { GET as callback } from '@/app/devin/outpost-callback/route' -import { DevinOAuthError } from '@/core/modules/devin-outposts/oauth.server' -import { DevinWorkerLaunchError } from '@/core/modules/devin-outposts/worker.server' - -const authContext = { - accessToken: 'dashboard-token', - user: { id: 'user-1' }, -} -const attempt = { - expiresAt: Date.now() + 60_000, - nonce: 'nonce', - operationId: '17d18dac-86d9-4a79-91e7-4477bd29327e', - returnOrigin: 'http://localhost:3000', - teamId: 'team-1', - teamSlug: 'test-team', - userId: 'user-1', - version: 1 as const, -} - -describe('Devin OAuth routes', () => { - beforeEach(() => { - vi.unstubAllEnvs() - for (const mock of Object.values(mocks)) mock.mockReset() - mocks.getAuthContext.mockResolvedValue(authContext) - mocks.getTeamIdFromSlug.mockResolvedValue({ ok: true, data: 'team-1' }) - mocks.isFeatureEnabled.mockResolvedValue(true) - mocks.createAttempt.mockReturnValue({ - attemptCookie: 'signed-attempt', - connectUrl: new URL('https://app.devin.ai/outposts/connect?test=1'), - }) - mocks.getConnectUrl.mockReturnValue( - new URL('https://app.devin.ai/outposts/connect?resume=1') - ) - mocks.readAttempt.mockReturnValue(null) - mocks.findStartedWorker.mockResolvedValue(null) - }) - - it('requires a Dashboard session before creating OAuth state', async () => { - mocks.getAuthContext.mockResolvedValue(null) - - const response = await start(startRequest()) - - expect(response.headers.get('location')).toBe( - 'http://localhost:3000/sign-in?returnTo=%2Fdashboard%2Ftest-team%2Fconnections%2Fdevin' - ) - expect(mocks.createAttempt).not.toHaveBeenCalled() - }) - - it('redirects to Devin without touching the worker runtime', async () => { - const response = await start(startRequest()) - - expect(response.status).toBe(303) - expect(response.headers.get('location')).toBe( - 'https://app.devin.ai/outposts/connect?test=1' - ) - expect(response.headers.get('set-cookie')).toContain( - 'e2b-devin-oauth=signed-attempt' - ) - expect(response.headers.get('set-cookie')).toContain('HttpOnly') - expect(mocks.createAttempt).toHaveBeenCalledWith( - expect.objectContaining({ - returnOrigin: 'http://localhost:3000', - teamId: 'team-1', - teamSlug: 'test-team', - userId: 'user-1', - }) - ) - expect(mocks.createAttempt.mock.calls[0]?.[0]).not.toHaveProperty( - 'sandboxId' - ) - expect(mocks.findStartedWorker).not.toHaveBeenCalled() - expect(mocks.launchWorker).not.toHaveBeenCalled() - }) - - it('resumes an active authorization without creating new state', async () => { - mocks.readAttempt.mockReturnValue(attempt) - - const response = await start(startRequest()) - - expect(response.headers.get('location')).toBe( - 'https://app.devin.ai/outposts/connect?resume=1' - ) - expect(mocks.getConnectUrl).toHaveBeenCalledWith(attempt) - expect(mocks.createAttempt).not.toHaveBeenCalled() - }) - - it('rejects cross-site OAuth starts before creating state', async () => { - const response = await start(startRequest('https://attacker.example')) - - expect(response.status).toBe(403) - expect(mocks.createAttempt).not.toHaveBeenCalled() - expect(mocks.launchWorker).not.toHaveBeenCalled() - }) - - it('does not start OAuth when Connections is disabled for the team', async () => { - mocks.isFeatureEnabled.mockResolvedValue(false) - - const response = await start(startRequest()) - - expect(response.status).toBe(404) - expect(mocks.createAttempt).not.toHaveBeenCalled() - }) - - it('rejects a callback without valid state and clears the cookie', async () => { - const response = await callback( - new NextRequest('http://localhost:8765/callback?code=one-time-code') - ) - - expect(response.headers.get('location')).toBe( - 'http://localhost:3000/dashboard?devinOAuth=invalid_state' - ) - expect(response.headers.get('set-cookie')).toContain( - 'e2b-devin-oauth=; Path=/; Expires=' - ) - expect(response.headers.get('referrer-policy')).toBe('no-referrer') - expect(mocks.exchangeCode).not.toHaveBeenCalled() - }) - - it('rejects a callback after the Dashboard user changes', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.getAuthContext.mockResolvedValue({ - ...authContext, - user: { id: 'user-2' }, - }) - - const response = await callback(callbackRequest()) - - expect(response.headers.get('location')).toContain('devinOAuth=session') - expect(mocks.exchangeCode).not.toHaveBeenCalled() - }) - - it('rejects a callback when the team binding changes', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.getTeamIdFromSlug.mockResolvedValue({ ok: true, data: 'team-2' }) - - const response = await callback(callbackRequest()) - - expect(response.headers.get('location')).toContain('devinOAuth=access') - expect(mocks.exchangeCode).not.toHaveBeenCalled() - expect(mocks.launchWorker).not.toHaveBeenCalled() - }) - - it('does not finish OAuth when Connections is disabled for the team', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.isFeatureEnabled.mockResolvedValue(false) - - const response = await callback(callbackRequest()) - - expect(response.status).toBe(404) - expect(response.headers.get('set-cookie')).toContain('Max-Age=0') - expect(mocks.exchangeCode).not.toHaveBeenCalled() - expect(mocks.launchWorker).not.toHaveBeenCalled() - }) - - it('recovers an existing worker before redeeming the code again', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.findStartedWorker.mockResolvedValue('sandbox-1') - - const response = await callback(callbackRequest('already-used')) - - expect(response.headers.get('location')).toBe( - 'http://localhost:3000/dashboard/test-team/sandboxes/sandbox-1/terminal' - ) - expect(mocks.exchangeCode).not.toHaveBeenCalled() - }) - - it('preserves state when worker recovery is unavailable', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.findStartedWorker.mockRejectedValue(new Error('infra unavailable')) - - const response = await callback(callbackRequest()) - - expect(response.headers.get('location')).toContain('devinOAuth=launch') - expect(response.headers.get('set-cookie')).toBeNull() - expect(mocks.exchangeCode).not.toHaveBeenCalled() - }) - - it('does not let an uncorrelated denial clear an active attempt', async () => { - mocks.readAttempt.mockReturnValue(attempt) - - const response = await callback( - callbackRequest(undefined, 'error=access_denied') - ) - - expect(response.headers.get('location')).toContain('devinOAuth=denied') - expect(response.headers.get('set-cookie')).toBeNull() - expect(mocks.exchangeCode).not.toHaveBeenCalled() - }) - - it('creates the worker only after exchanging the Devin code', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.exchangeCode.mockResolvedValue({ - accessToken: 'scoped-token', - apiUrl: 'https://api.devin.ai', - poolId: 'pool-1', - }) - mocks.launchWorker.mockResolvedValue({ sandboxId: 'sandbox-1' }) - - const response = await callback(callbackRequest()) - - expect(mocks.exchangeCode).toHaveBeenCalledWith('one-time-code', attempt) - expect(mocks.launchWorker).toHaveBeenCalledWith({ - accessToken: 'dashboard-token', - apiUrl: 'https://api.devin.ai', - operationId: attempt.operationId, - outpostsToken: 'scoped-token', - poolId: 'pool-1', - teamId: 'team-1', - userId: 'user-1', - }) - expect(mocks.exchangeCode.mock.invocationCallOrder[0]).toBeLessThan( - mocks.launchWorker.mock.invocationCallOrder[0] ?? 0 - ) - expect(response.headers.get('location')).toContain( - '/sandboxes/sandbox-1/terminal' - ) - expect(response.headers.get('set-cookie')).toContain('Max-Age=0') - }) - - it('clears consumed authorization state when worker creation fails', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.exchangeCode.mockResolvedValue({ - accessToken: 'scoped-token', - apiUrl: 'https://api.devin.ai', - poolId: 'pool-1', - }) - mocks.launchWorker.mockRejectedValue(new DevinWorkerLaunchError()) - - const response = await callback(callbackRequest()) - - expect(response.headers.get('location')).toContain('devinOAuth=launch') - expect(response.headers.get('set-cookie')).toContain('Max-Age=0') - }) - - it('treats invalid_grant as restartable without creating a worker', async () => { - mocks.readAttempt.mockReturnValue(attempt) - mocks.exchangeCode.mockRejectedValue(new DevinOAuthError('invalid_grant')) - - const response = await callback(callbackRequest('expired')) - - expect(response.headers.get('location')).toContain('devinOAuth=expired') - expect(response.headers.get('set-cookie')).toContain('Max-Age=0') - expect(mocks.launchWorker).not.toHaveBeenCalled() - - mocks.readAttempt.mockReturnValue(null) - await start(startRequest()) - expect(mocks.createAttempt).toHaveBeenCalledOnce() - }) -}) - -function startRequest(origin = 'http://localhost:3000') { - return new NextRequest( - 'http://localhost:3000/api/connections/devin/start?teamSlug=test-team', - { headers: { origin } } - ) -} - -function callbackRequest(code = 'one-time-code', query?: string) { - const suffix = query ?? `code=${encodeURIComponent(code)}` - return new NextRequest(`http://localhost:8765/callback?${suffix}`) -} diff --git a/tests/unit/devin-outposts-worker.test.ts b/tests/unit/devin-outposts-worker.test.ts index 0b08b9294..2eeee94fc 100644 --- a/tests/unit/devin-outposts-worker.test.ts +++ b/tests/unit/devin-outposts-worker.test.ts @@ -36,7 +36,6 @@ vi.mock('e2b', () => ({ import { DevinWorkerLaunchError, disconnectDevinWorkers, - findStartedDevinWorker, launchDevinWorker, } from '@/core/modules/devin-outposts/worker.server' @@ -260,45 +259,6 @@ describe('Devin worker launcher', () => { }) }) - it('finds a started worker for callback response-loss recovery', async () => { - mocks.infraGet.mockResolvedValue( - apiResult(200, [sandboxApiResponse('existing-sbx')]) - ) - mocks.infraPost.mockResolvedValue( - apiResult(200, sandboxApiResponse('existing-sbx')) - ) - mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'running' }]) - - await expect(findStartedDevinWorker(input)).resolves.toBe('existing-sbx') - expect(mocks.infraPost).toHaveBeenNthCalledWith( - 1, - '/sandboxes/{sandboxID}/connect', - expect.objectContaining({ body: { timeout: 1800 } }) - ) - expect(mocks.infraPost).toHaveBeenNthCalledWith( - 2, - '/sandboxes/{sandboxID}/connect', - expect.objectContaining({ body: { timeout: 86_400 } }) - ) - }) - - it('does not report an incomplete worker as recovered', async () => { - mocks.infraGet.mockResolvedValue( - apiResult(200, [sandboxApiResponse('existing-sbx')]) - ) - mocks.infraPost.mockResolvedValue( - apiResult(200, sandboxApiResponse('existing-sbx')) - ) - mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'stopped' }]) - - await expect(findStartedDevinWorker(input)).resolves.toBeNull() - expect(mocks.infraPost).toHaveBeenCalledTimes(1) - expect(mocks.infraPost).toHaveBeenCalledWith( - '/sandboxes/{sandboxID}/connect', - expect.objectContaining({ body: { timeout: 1800 } }) - ) - }) - it('disconnects every Devin worker owned by the current user', async () => { mocks.infraGet .mockResolvedValueOnce( From 7ace18362e095a76196552b5872ab0bc1745e265 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 17:36:12 -0700 Subject: [PATCH 07/16] Make Devin worker retries ownership-safe Only clean up sandboxes created by the current request, and poll briefly for metadata after an ambiguous create response. This prevents retries from deleting existing workers or immediately stranding delayed creations. --- .../modules/devin-outposts/worker.server.ts | 84 +++++++++++++------ tests/unit/devin-outposts-worker.test.ts | 59 +++++++++++-- 2 files changed, 113 insertions(+), 30 deletions(-) diff --git a/src/core/modules/devin-outposts/worker.server.ts b/src/core/modules/devin-outposts/worker.server.ts index 9c1432cde..995f4709d 100644 --- a/src/core/modules/devin-outposts/worker.server.ts +++ b/src/core/modules/devin-outposts/worker.server.ts @@ -10,6 +10,8 @@ import { normalizeDevinApiUrl } from './client.server' const ACTIVE_WORKER_TIMEOUT_MS = 24 * 60 * 60 * 1000 const API_REQUEST_TIMEOUT_MS = 10_000 +const CREATE_RECOVERY_ATTEMPTS = 5 +const CREATE_RECOVERY_INTERVAL_MS = 200 const DISCONNECT_CONCURRENCY = 10 const DEFAULT_DEVIN_TEMPLATE = 'devin-outposts-worker' const PREPARED_SANDBOX_TIMEOUT_MS = 30 * 60 * 1000 @@ -30,18 +32,19 @@ export type LaunchDevinWorkerInput = WorkerIdentity & { type StartPreparedDevinWorkerInput = LaunchDevinWorkerInput & { activeTimeoutMs: number + cleanupOnFailure: boolean sandboxId: string } type PreparedWorkerIdentity = WorkerIdentity & { activeTimeoutMs: number + cleanupOnFailure: boolean sandboxId: string } type PreparedDevinWorker = { - acceptorId: string + cleanupOnFailure: boolean sandboxId: string - started: boolean } export type LaunchDevinWorkerResult = { @@ -62,12 +65,13 @@ export async function launchDevinWorker( ): Promise { const activeTimeoutMs = await getActiveWorkerTimeoutMs(input) const existingSandboxId = await findWorkerSandbox(input) - const prepared = existingSandboxId - ? { sandboxId: existingSandboxId } + const prepared: PreparedDevinWorker = existingSandboxId + ? { cleanupOnFailure: false, sandboxId: existingSandboxId } : await prepareDevinWorkerSandbox(input) return startPreparedDevinWorker({ ...input, activeTimeoutMs, + cleanupOnFailure: prepared.cleanupOnFailure, sandboxId: prepared.sandboxId, }) } @@ -103,19 +107,13 @@ export async function disconnectDevinWorkers( async function prepareDevinWorkerSandbox( input: WorkerIdentity ): Promise { - const acceptorId = acceptorIdFor(input.operationId) - try { const sandbox = await createWorkerSandbox(input) - return { acceptorId, sandboxId: sandbox.sandboxID, started: false } + return { cleanupOnFailure: true, sandboxId: sandbox.sandboxID } } catch (error) { - try { - const recoveredSandboxId = await findWorkerSandbox(input) - if (recoveredSandboxId) { - return { acceptorId, sandboxId: recoveredSandboxId, started: false } - } - } catch { - // Preserve the original create failure below. + const recoveredSandboxId = await recoverCreatedWorkerSandbox(input) + if (recoveredSandboxId) { + return { cleanupOnFailure: false, sandboxId: recoveredSandboxId } } l.warn( @@ -164,10 +162,24 @@ async function persistPreparedDevinConnection( async function startPreparedDevinWorker( input: StartPreparedDevinWorkerInput ): Promise { + if (!input.cleanupOnFailure) { + try { + const sandbox = await connectWorkerSandbox( + input, + PREPARED_SANDBOX_TIMEOUT_MS + ) + if (await sandboxHasStartedWorker(sandbox, input.operationId)) { + return reusedWorkerResult(input) + } + } catch { + throw new DevinWorkerLaunchError() + } + } + try { await persistPreparedDevinConnection(input) } catch { - return cleanupFailedWorkerLaunch(input) + return failWorkerLaunch(input) } return startPersistedDevinWorker(input) } @@ -182,12 +194,7 @@ async function startPersistedDevinWorker( try { sandbox = await connectWorkerSandbox(input, PREPARED_SANDBOX_TIMEOUT_MS) if (await sandboxHasStartedWorker(sandbox, input.operationId)) { - return { - acceptorId, - reused: true, - sandboxId: input.sandboxId, - workerPid: null, - } + return reusedWorkerResult(input) } const result = await sandbox.commands.run( @@ -226,13 +233,24 @@ async function startPersistedDevinWorker( workerPid: result.stdout.trim(), } } catch { - return cleanupFailedWorkerLaunch(input) + return failWorkerLaunch(input) } } -async function cleanupFailedWorkerLaunch( +function reusedWorkerResult( input: PreparedWorkerIdentity -): Promise { +): LaunchDevinWorkerResult { + return { + acceptorId: acceptorIdFor(input.operationId), + reused: true, + sandboxId: input.sandboxId, + workerPid: null, + } +} + +async function failWorkerLaunch(input: PreparedWorkerIdentity): Promise { + if (!input.cleanupOnFailure) throw new DevinWorkerLaunchError() + const cleaned = await cleanupPreparedDevinWorker(input).catch(() => false) if (!cleaned) { l.error( @@ -308,6 +326,24 @@ async function findWorkerSandbox(input: WorkerIdentity) { )?.sandboxID } +async function recoverCreatedWorkerSandbox(input: WorkerIdentity) { + for (let attempt = 0; attempt < CREATE_RECOVERY_ATTEMPTS; attempt++) { + try { + const sandboxId = await findWorkerSandbox(input) + if (sandboxId) return sandboxId + } catch { + // An ambiguous create failure can coincide with transient list errors. + } + + if (attempt < CREATE_RECOVERY_ATTEMPTS - 1) { + await new Promise((resolve) => + setTimeout(resolve, CREATE_RECOVERY_INTERVAL_MS) + ) + } + } + return undefined +} + async function listWorkerSandboxes( input: Pick ) { diff --git a/tests/unit/devin-outposts-worker.test.ts b/tests/unit/devin-outposts-worker.test.ts index 2eeee94fc..f29e21051 100644 --- a/tests/unit/devin-outposts-worker.test.ts +++ b/tests/unit/devin-outposts-worker.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ infraDelete: vi.fn(), @@ -73,6 +73,10 @@ describe('Devin worker launcher', () => { }) }) + afterEach(() => { + vi.useRealTimers() + }) + it('starts the worker through the bearer-authenticated sandbox API', async () => { const sandbox = sandboxWithResults([ { exitCode: 0, stdout: '' }, @@ -143,17 +147,14 @@ describe('Devin worker launcher', () => { } throw new Error(`unexpected path ${path}`) }) - mocks.runtime = sandboxWithResults([ - { exitCode: 0, stdout: '' }, - { exitCode: 0, stdout: 'running' }, - ]) + mocks.runtime = sandboxWithResults([{ exitCode: 0, stdout: 'running' }]) await expect(launchDevinWorker(input)).resolves.toMatchObject({ reused: true, sandboxId: 'existing-sbx', workerPid: null, }) - expect(mocks.runtime.commands.run).toHaveBeenCalledTimes(2) + expect(mocks.runtime.commands.run).toHaveBeenCalledOnce() expect(mocks.infraPost).not.toHaveBeenCalledWith( '/sandboxes', expect.anything() @@ -176,6 +177,7 @@ describe('Devin worker launcher', () => { throw new Error(`unexpected path ${path}`) }) mocks.runtime = sandboxWithResults([ + { exitCode: 0, stdout: 'stopped' }, { exitCode: 0, stdout: '' }, { exitCode: 0, stdout: 'stopped' }, { exitCode: 0, stdout: '4242' }, @@ -188,6 +190,51 @@ describe('Devin worker launcher', () => { expect(mocks.infraGet).toHaveBeenCalledTimes(2) }) + it('waits for a created sandbox to become visible after response loss', async () => { + vi.useFakeTimers() + mocks.infraGet + .mockResolvedValueOnce(apiResult(200, [])) + .mockResolvedValueOnce(apiResult(200, [])) + .mockResolvedValueOnce( + apiResult(200, [sandboxApiResponse('recovered-sbx')]) + ) + mocks.infraPost.mockImplementation((path: string) => { + if (path === '/sandboxes') { + return Promise.reject(new Error('create response timed out')) + } + if (path === '/sandboxes/{sandboxID}/connect') { + return apiResult(200, sandboxApiResponse('recovered-sbx')) + } + throw new Error(`unexpected path ${path}`) + }) + mocks.runtime = sandboxWithResults([ + { exitCode: 0, stdout: 'stopped' }, + { exitCode: 0, stdout: '' }, + { exitCode: 0, stdout: 'stopped' }, + { exitCode: 0, stdout: '4242' }, + ]) + + const launch = launchDevinWorker(input) + await vi.advanceTimersByTimeAsync(200) + + await expect(launch).resolves.toMatchObject({ + sandboxId: 'recovered-sbx', + }) + expect(mocks.infraGet).toHaveBeenCalledTimes(3) + }) + + it('does not delete a pre-existing worker when inspection fails', async () => { + mocks.infraGet.mockResolvedValue( + apiResult(200, [sandboxApiResponse('existing-sbx')]) + ) + mocks.infraPost.mockRejectedValue(new Error('connect failed')) + + await expect(launchDevinWorker(input)).rejects.toBeInstanceOf( + DevinWorkerLaunchError + ) + expect(mocks.infraDelete).not.toHaveBeenCalled() + }) + it('keeps the scoped credential out of metadata and command text', async () => { const sandbox = sandboxWithResults([ { exitCode: 0, stdout: '' }, From dddadc4ce245326b251309e5c6711c533ea42fe5 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 17:38:41 -0700 Subject: [PATCH 08/16] Coordinate Devin connection operations Bind retries to a digest of the exact launch target, serialize discovery, launch, and disconnect actions, and ignore late results after navigation. Credentials remain out of mutation caches and retry state. --- .../connections/devin-connection-form.tsx | 137 +++++++++++++----- .../connections/devin-launch-attempt.ts | 35 +++++ tests/unit/devin-launch-attempt.test.ts | 45 ++++++ 3 files changed, 179 insertions(+), 38 deletions(-) create mode 100644 src/features/dashboard/connections/devin-launch-attempt.ts create mode 100644 tests/unit/devin-launch-attempt.test.ts diff --git a/src/features/dashboard/connections/devin-connection-form.tsx b/src/features/dashboard/connections/devin-connection-form.tsx index 148b71fae..6f0da87c9 100644 --- a/src/features/dashboard/connections/devin-connection-form.tsx +++ b/src/features/dashboard/connections/devin-connection-form.tsx @@ -1,7 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' -import { useReducer, useRef, useState } from 'react' +import { useEffect, useReducer, useRef, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { defaultErrorToast, @@ -26,6 +26,10 @@ import { SelectTrigger, SelectValue, } from '@/ui/primitives/select' +import { + type DevinLaunchAttempt, + getDevinLaunchAttempt, +} from './devin-launch-attempt' type Organization = { id: string; name: string } type Pool = { id: string; name: string; platform: string } @@ -33,8 +37,6 @@ type Pool = { id: string; name: string; platform: string } type ManualConnectionState = { accountChecked: boolean apiKey: string - discoverPending: boolean - launchPending: boolean organizations: Organization[] outpostsToken: string poolId: string @@ -44,8 +46,6 @@ type ManualConnectionState = { const initialManualConnectionState: ManualConnectionState = { accountChecked: false, apiKey: '', - discoverPending: false, - launchPending: false, organizations: [], outpostsToken: '', poolId: '', @@ -56,7 +56,12 @@ type DevinConnectionFormProps = { teamSlug: string } +type PendingOperation = 'disconnect' | 'discover' | 'launch' | null + export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { + const [pendingOperation, setPendingOperation] = + useState(null) + return (
@@ -75,25 +80,44 @@ export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) {

- - + +
) } -function DevinWorkerControls({ teamSlug }: DevinConnectionFormProps) { +type OperationProps = DevinConnectionFormProps & { + pendingOperation: PendingOperation + setPendingOperation: (operation: PendingOperation) => void +} + +function DevinWorkerControls({ + pendingOperation, + setPendingOperation, + teamSlug, +}: OperationProps) { const trpcClient = useTRPCClient() const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false) - const [disconnectPending, setDisconnectPending] = useState(false) + const mounted = useMountedRef() + const disconnectPending = pendingOperation === 'disconnect' async function disconnectWorkers() { - if (disconnectPending) return - setDisconnectPending(true) + if (pendingOperation) return + setPendingOperation('disconnect') try { const data = await trpcClient.connections.disconnectDevinWorkers.mutate({ confirm: true, teamSlug, }) + if (!mounted.current) return toast( defaultSuccessToast( data.count === 0 @@ -103,6 +127,7 @@ function DevinWorkerControls({ teamSlug }: DevinConnectionFormProps) { ) setDisconnectDialogOpen(false) } catch (error) { + if (!mounted.current) return toast( defaultErrorToast( error instanceof Error && error.message @@ -111,7 +136,7 @@ function DevinWorkerControls({ teamSlug }: DevinConnectionFormProps) { ) ) } - setDisconnectPending(false) + if (mounted.current) setPendingOperation(null) } return ( @@ -129,11 +154,15 @@ function DevinWorkerControls({ teamSlug }: DevinConnectionFormProps) { confirm="Disconnect workers" onConfirm={disconnectWorkers} confirmProps={{ - disabled: disconnectPending, + disabled: pendingOperation !== null, loading: disconnectPending ? 'Disconnecting workers' : undefined, }} trigger={ - @@ -143,7 +172,11 @@ function DevinWorkerControls({ teamSlug }: DevinConnectionFormProps) { ) } -function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { +function ManualDevinConnection({ + pendingOperation, + setPendingOperation, + teamSlug, +}: OperationProps) { const router = useRouter() const trpcClient = useTRPCClient() const apiUrlRef = useRef(null) @@ -157,14 +190,16 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { const { accountChecked, apiKey, - discoverPending, - launchPending, organizations, outpostsToken, poolId, pools, } = state - const launchOperationId = useRef(null) + const launchAttempt = useRef(null) + const mounted = useMountedRef() + const discoverPending = pendingOperation === 'discover' + const launchPending = pendingOperation === 'launch' + const anyOperationPending = pendingOperation !== null const launchDisabledReason = (() => { if (!accountChecked) return 'Check the Devin account first.' @@ -185,15 +220,17 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { async function checkAccount(event: React.FormEvent) { event.preventDefault() - if (!apiKey.trim() || discoverPending || launchPending) return + if (!apiKey.trim() || pendingOperation) return const submittedApiKey = apiKey - updateState({ apiKey: '', discoverPending: true }) + updateState({ apiKey: '' }) + setPendingOperation('discover') try { const data = await trpcClient.connections.discoverDevin.mutate({ apiKey: submittedApiKey, apiUrl: apiUrlRef.current?.value || 'https://api.devin.ai', teamSlug, }) + if (!mounted.current) return updateState({ accountChecked: true, organizations: data.organizations, @@ -202,6 +239,7 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { }) toast(defaultSuccessToast('Devin account checked.')) } catch (error) { + if (!mounted.current) return resetDiscovery() toast( defaultErrorToast( @@ -211,26 +249,31 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { ) ) } - updateState({ discoverPending: false }) + if (mounted.current) setPendingOperation(null) } async function startWorker(event: React.FormEvent) { event.preventDefault() - if (launchDisabledReason || launchPending) return - const submittedToken = outpostsToken - updateState({ launchPending: true, outpostsToken: '' }) - if (!launchOperationId.current) { - launchOperationId.current = crypto.randomUUID() + if (launchDisabledReason || pendingOperation) return + const launchPayload = { + apiUrl: apiUrlRef.current?.value || 'https://api.devin.ai', + outpostsToken, + poolId, } + setPendingOperation('launch') + updateState({ outpostsToken: '' }) try { + launchAttempt.current = await getDevinLaunchAttempt( + launchAttempt.current, + launchPayload + ) const data = await trpcClient.connections.launchDevinWorker.mutate({ - apiUrl: apiUrlRef.current?.value || 'https://api.devin.ai', - operationId: launchOperationId.current, - outpostsToken: submittedToken, - poolId, + ...launchPayload, + operationId: launchAttempt.current.operationId, teamSlug, }) - launchOperationId.current = null + if (!mounted.current) return + launchAttempt.current = null toast( defaultSuccessToast( data.reused @@ -240,6 +283,7 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { ) router.push(PROTECTED_URLS.SANDBOX_TERMINAL(teamSlug, data.sandboxId)) } catch (error) { + if (!mounted.current) return toast( defaultErrorToast( error instanceof Error && error.message @@ -248,7 +292,7 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { ) ) } - updateState({ launchPending: false }) + if (mounted.current) setPendingOperation(null) } return ( @@ -271,7 +315,9 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { { @@ -297,7 +343,7 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) { @@ -397,7 +447,7 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) {

- Discover your Devin organizations and pools from the dashboard, then - start a worker using only a scoped machine token inside the sandbox. + Discover your Devin Outposts pools from the dashboard, then start a + worker using only a scoped machine token inside the sandbox.

@@ -187,14 +184,7 @@ function ManualDevinConnection({ ) => ({ ...current, ...update }), initialManualConnectionState ) - const { - accountChecked, - apiKey, - organizations, - outpostsToken, - poolId, - pools, - } = state + const { accountChecked, apiKey, outpostsToken, poolId, pools } = state const launchAttempt = useRef(null) const mounted = useMountedRef() const discoverPending = pendingOperation === 'discover' @@ -211,7 +201,6 @@ function ManualDevinConnection({ function resetDiscovery() { updateState({ accountChecked: false, - organizations: [], outpostsToken: '', poolId: '', pools: [], @@ -233,7 +222,6 @@ function ManualDevinConnection({ if (!mounted.current) return updateState({ accountChecked: true, - organizations: data.organizations, poolId: data.pools.length === 1 ? data.pools[0]?.id || '' : '', pools: data.pools, }) @@ -324,7 +312,6 @@ function ManualDevinConnection({ updateState({ accountChecked: false, apiKey: event.target.value, - organizations: [], outpostsToken: '', poolId: '', pools: [], @@ -381,13 +368,7 @@ function ManualDevinConnection({
{accountChecked ? (

- Account access verified for{' '} - - {organizations - .map((organization) => organization.name) - .join(', ')} - - . + Devin account access verified.

) : null} diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx index ee4aee309..f5f5c5ce0 100644 --- a/src/trpc/client.tsx +++ b/src/trpc/client.tsx @@ -9,8 +9,8 @@ import { createTRPCContext } from '@trpc/tanstack-react-query' import { useState } from 'react' import SuperJSON from 'superjson' import type { TRPCAppRouter } from '@/core/server/api/routers' -import { sanitizeClientInput } from '@/core/shared/observability/sanitize-input' import { createQueryClient } from './query-client' +import { sanitizeTRPCLoggerArgs } from './sanitize-logger-args' export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext() @@ -68,8 +68,8 @@ export function TRPCReactProvider( links: [ loggerLink({ console: { - error: (...args) => console.error(...sanitizeLoggerArgs(args)), - log: (...args) => console.log(...sanitizeLoggerArgs(args)), + error: (...args) => console.error(...sanitizeTRPCLoggerArgs(args)), + log: (...args) => console.log(...sanitizeTRPCLoggerArgs(args)), }, enabled: (opts) => (process.env.NODE_ENV === 'development' && @@ -97,21 +97,3 @@ export function TRPCReactProvider( ) } - -function sanitizeLoggerArgs(args: unknown[]) { - return args.map((argument) => { - if ( - !argument || - typeof argument !== 'object' || - Array.isArray(argument) || - !('input' in argument) - ) { - return argument - } - - return { - ...argument, - input: sanitizeClientInput(argument.input), - } - }) -} diff --git a/src/trpc/sanitize-logger-args.ts b/src/trpc/sanitize-logger-args.ts new file mode 100644 index 000000000..a9f52efc1 --- /dev/null +++ b/src/trpc/sanitize-logger-args.ts @@ -0,0 +1,19 @@ +import { sanitizeClientInput } from '@/core/shared/observability/sanitize-input' + +export function sanitizeTRPCLoggerArgs(args: unknown[]) { + return args.map((argument) => { + if ( + !argument || + typeof argument !== 'object' || + Array.isArray(argument) || + !('input' in argument) + ) { + return argument + } + + return { + ...argument, + input: sanitizeClientInput(argument.input), + } + }) +} diff --git a/tests/unit/action-input-summary.test.ts b/tests/unit/action-input-summary.test.ts index 30a93a095..5174a2a93 100644 --- a/tests/unit/action-input-summary.test.ts +++ b/tests/unit/action-input-summary.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { sanitizeClientInput } from '@/core/server/actions/utils' +import { sanitizeClientInput } from '@/core/shared/observability/sanitize-input' describe('sanitizeClientInput', () => { it('inlines allowlisted scalar keys verbatim', () => { @@ -47,6 +47,9 @@ describe('sanitizeClientInput', () => { signatureSecret: 'a'.repeat(64), password: 'hunter2', apiToken: 'tok_secret', + apiKey: 'cog_secret', + outpostsToken: 'outposts_secret', + poolId: 'pool_secret_shaped', privateKey: '-----BEGIN PRIVATE KEY-----\n…', }) @@ -54,6 +57,9 @@ describe('sanitizeClientInput', () => { _signatureSecret: 'string(64)', _password: 'string(7)', _apiToken: 'string(10)', + _apiKey: 'string(10)', + _outpostsToken: 'string(15)', + _poolId: 'string(18)', _privateKey: `string(${'-----BEGIN PRIVATE KEY-----\n…'.length})`, }) @@ -61,6 +67,9 @@ describe('sanitizeClientInput', () => { const serialized = JSON.stringify(out) expect(serialized).not.toContain('hunter2') expect(serialized).not.toContain('tok_secret') + expect(serialized).not.toContain('cog_secret') + expect(serialized).not.toContain('outposts_secret') + expect(serialized).not.toContain('pool_secret_shaped') expect(serialized).not.toContain('BEGIN PRIVATE KEY') expect(serialized).not.toContain('a'.repeat(64)) }) diff --git a/tests/unit/connections-router.test.ts b/tests/unit/connections-router.test.ts index d7e6bdc0f..055ed9090 100644 --- a/tests/unit/connections-router.test.ts +++ b/tests/unit/connections-router.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTRPCContext } from '@/core/server/trpc/init' +import { l } from '@/core/shared/clients/logger/logger' const mocks = vi.hoisted(() => ({ + disconnectDevinWorkers: vi.fn(), + discoverDevinAccount: vi.fn(), featureEnabled: vi.fn(), getAuthContext: vi.fn(), getTeamIdFromSlug: vi.fn(), @@ -22,23 +25,34 @@ vi.mock('@/core/modules/feature-flags/feature-flags.server', () => ({ })) vi.mock('@/core/modules/devin-outposts/client.server', () => ({ - DevinConnectionError: class DevinConnectionError extends Error {}, - discoverDevinAccount: vi.fn(), + DevinConnectionError: class DevinConnectionError extends Error { + constructor( + message: string, + readonly kind: string + ) { + super(message) + } + }, + discoverDevinAccount: mocks.discoverDevinAccount, normalizeDevinApiUrl: mocks.normalizeDevinApiUrl, })) vi.mock('@/core/modules/devin-outposts/worker.server', () => ({ DevinWorkerLaunchError: class DevinWorkerLaunchError extends Error {}, - disconnectDevinWorkers: vi.fn(), + disconnectDevinWorkers: mocks.disconnectDevinWorkers, launchDevinWorker: mocks.launchDevinWorker, })) const { createCallerFactory } = await import('@/core/server/trpc/init') +const { DevinConnectionError } = await import( + '@/core/modules/devin-outposts/client.server' +) const { connectionsRouter } = await import( '@/core/server/api/routers/connections' ) const createCaller = createCallerFactory(connectionsRouter) +const infoSpy = vi.spyOn(l, 'info').mockImplementation(() => undefined) const input = { teamSlug: 'customer-team', apiUrl: 'https://api.devin.ai', @@ -63,6 +77,8 @@ describe('connectionsRouter.launchDevinWorker', () => { data: 'resolved-team-id', }) mocks.featureEnabled.mockResolvedValue(true) + mocks.disconnectDevinWorkers.mockResolvedValue({ count: 1 }) + mocks.discoverDevinAccount.mockResolvedValue({ pools: [] }) mocks.launchDevinWorker.mockResolvedValue({ acceptorId: 'acceptor-1', reused: false, @@ -101,5 +117,50 @@ describe('connectionsRouter.launchDevinWorker', () => { teamId: 'resolved-team-id', userId: 'user-1', }) + const successLog = infoSpy.mock.calls.find( + ([context]) => context.key === 'trpc:procedure_success' + )?.[0] + expect(successLog).toMatchObject({ + 'trpc.procedure.input': { + _apiUrl: 'string(20)', + _operationId: 'string(36)', + _outpostsToken: 'string(21)', + _poolId: 'string(6)', + teamSlug: 'customer-team', + }, + }) + expect(JSON.stringify(successLog)).not.toContain(input.outpostsToken) + }) + + it('disconnects with the server-resolved session and team identity', async () => { + await (await caller()).disconnectDevinWorkers({ + confirm: true, + teamSlug: input.teamSlug, + }) + + expect(mocks.disconnectDevinWorkers).toHaveBeenCalledWith({ + accessToken: 'dashboard-access-token', + teamId: 'resolved-team-id', + userId: 'user-1', + }) + }) + + it.each([ + ['credentials', 'UNAUTHORIZED'], + ['url', 'BAD_REQUEST'], + ['provider', 'BAD_GATEWAY'], + ['response', 'BAD_GATEWAY'], + ] as const)('maps Devin %s errors to %s', async (kind, code) => { + mocks.discoverDevinAccount.mockRejectedValue( + new DevinConnectionError('Discovery failed', kind) + ) + + await expect( + (await caller()).discoverDevin({ + apiKey: 'service-user-key', + apiUrl: input.apiUrl, + teamSlug: input.teamSlug, + }) + ).rejects.toMatchObject({ code }) }) }) diff --git a/tests/unit/devin-outposts-client.test.ts b/tests/unit/devin-outposts-client.test.ts index 5f848a91c..9eca1a116 100644 --- a/tests/unit/devin-outposts-client.test.ts +++ b/tests/unit/devin-outposts-client.test.ts @@ -3,7 +3,6 @@ import { DevinConnectionError, discoverDevinAccount, normalizeDevinApiUrl, - organizationsFromPayload, poolsFromPayload, } from '@/core/modules/devin-outposts/client.server' @@ -33,18 +32,6 @@ describe('Devin Outposts API boundary', () => { expect(() => normalizeDevinApiUrl(value)).toThrow(DevinConnectionError) }) - it('parses organizations and ignores malformed records', () => { - expect( - organizationsFromPayload({ - items: [ - { org_id: 'org-1', name: 'Engineering' }, - { name: 'Missing ID' }, - null, - ], - }) - ).toEqual([{ id: 'org-1', name: 'Engineering' }]) - }) - it('parses Outposts pools and ignores incomplete records', () => { expect( poolsFromPayload({ @@ -60,9 +47,6 @@ describe('Devin Outposts API boundary', () => { }) it('rejects malformed collection payloads', () => { - expect(() => organizationsFromPayload({})).toThrow( - 'Devin returned an unexpected response' - ) expect(() => poolsFromPayload({ items: null })).toThrow( 'Devin returned an unexpected response' ) @@ -86,10 +70,7 @@ describe('Devin Outposts API boundary', () => { it('rejects malformed pool responses after authenticated discovery', async () => { const fetchMock = vi .fn() - .mockResolvedValueOnce( - Response.json({ org_id: 'org-1' }, { status: 200 }) - ) - .mockResolvedValueOnce(Response.json({}, { status: 200 })) + .mockResolvedValue(Response.json({}, { status: 200 })) vi.stubGlobal('fetch', fetchMock) await expect( @@ -97,15 +78,36 @@ describe('Devin Outposts API boundary', () => { ).rejects.toMatchObject({ kind: 'response' }) expect(fetchMock).toHaveBeenNthCalledWith( 1, - 'https://api.devin.ai/v3/self', + 'https://api.devin.ai/opbeta/outposts/pools', expect.objectContaining({ headers: { Authorization: 'Bearer test-key' }, }) ) - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - 'https://api.devin.ai/opbeta/outposts/pools', - expect.any(Object) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('discovers pools without requesting enterprise organizations', async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + items: [ + { + metadata: { pool_id: 'pool-1' }, + spec: { name: 'Linux workers', platform: 'linux' }, + }, + ], + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect( + discoverDevinAccount('https://api.devin.ai', 'test-key') + ).resolves.toEqual({ + pools: [{ id: 'pool-1', name: 'Linux workers', platform: 'linux' }], + }) + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining('/v3/enterprise/organizations'), + expect.anything() ) }) }) diff --git a/tests/unit/devin-outposts-worker.test.ts b/tests/unit/devin-outposts-worker.test.ts index f29e21051..d096ec1f8 100644 --- a/tests/unit/devin-outposts-worker.test.ts +++ b/tests/unit/devin-outposts-worker.test.ts @@ -307,6 +307,10 @@ describe('Devin worker launcher', () => { }) it('disconnects every Devin worker owned by the current user', async () => { + const metadata = new URLSearchParams({ + source: 'dashboard-devin-outposts', + userId: input.userId, + }).toString() mocks.infraGet .mockResolvedValueOnce( apiResult(200, [sandboxApiResponse('worker-1')], { @@ -329,10 +333,15 @@ describe('Devin worker launcher', () => { expect.objectContaining({ params: { query: expect.objectContaining({ + metadata, nextToken: undefined, state: ['running', 'paused'], }), }, + headers: { + Authorization: 'Bearer dashboard-access-token', + 'X-Team-ID': 'team-1', + }, }) ) expect(mocks.infraGet).toHaveBeenNthCalledWith( @@ -340,7 +349,11 @@ describe('Devin worker launcher', () => { '/v2/sandboxes', expect.objectContaining({ params: { - query: expect.objectContaining({ nextToken: 'next-page' }), + query: expect.objectContaining({ metadata, nextToken: 'next-page' }), + }, + headers: { + Authorization: 'Bearer dashboard-access-token', + 'X-Team-ID': 'team-1', }, }) ) diff --git a/tests/unit/telemetry-input.test.ts b/tests/unit/telemetry-input.test.ts deleted file mode 100644 index 3aaa09411..000000000 --- a/tests/unit/telemetry-input.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { sanitizeClientInput } from '@/core/shared/observability/sanitize-input' - -describe('tRPC telemetry input', () => { - it('keeps identifiers and replaces credentials with type hints', () => { - expect( - sanitizeClientInput({ - apiKey: 'broad-secret', - outpostsToken: 'scoped-secret', - poolId: 'pool-secret-shaped', - teamSlug: 'example-team', - }) - ).toEqual({ - _apiKey: 'string(12)', - _outpostsToken: 'string(13)', - _poolId: 'string(18)', - teamSlug: 'example-team', - }) - }) -}) diff --git a/tests/unit/trpc-logger-input.test.ts b/tests/unit/trpc-logger-input.test.ts new file mode 100644 index 000000000..6c3cd9634 --- /dev/null +++ b/tests/unit/trpc-logger-input.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeTRPCLoggerArgs } from '@/trpc/sanitize-logger-args' + +describe('tRPC browser logger input', () => { + it.each([ + 'up', + 'down', + ])('sanitizes %s logger payloads at the console sink', (direction) => { + const args = sanitizeTRPCLoggerArgs([ + direction, + { + input: { + apiKey: 'broad-secret', + outpostsToken: 'scoped-secret', + teamSlug: 'example-team', + }, + result: direction === 'down' ? new Error('failed') : undefined, + }, + ]) + + expect(args[1]).toMatchObject({ + input: { + _apiKey: 'string(12)', + _outpostsToken: 'string(13)', + teamSlug: 'example-team', + }, + }) + expect(JSON.stringify(args)).not.toContain('broad-secret') + expect(JSON.stringify(args)).not.toContain('scoped-secret') + }) +}) From 831bbc88588cf0956be821e69205b2f7e272080b Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 17:45:19 -0700 Subject: [PATCH 10/16] Clean up recovered Devin launch sandboxes Track inspection and cleanup independently so delayed create recovery can reuse a running worker, preserve pre-existing workers, and remove a recovered stopped sandbox when setup fails. --- .../modules/devin-outposts/worker.server.ts | 24 +++++++++++--- tests/unit/devin-outposts-worker.test.ts | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/core/modules/devin-outposts/worker.server.ts b/src/core/modules/devin-outposts/worker.server.ts index 995f4709d..d78aaba35 100644 --- a/src/core/modules/devin-outposts/worker.server.ts +++ b/src/core/modules/devin-outposts/worker.server.ts @@ -33,17 +33,20 @@ export type LaunchDevinWorkerInput = WorkerIdentity & { type StartPreparedDevinWorkerInput = LaunchDevinWorkerInput & { activeTimeoutMs: number cleanupOnFailure: boolean + inspectBeforeStart: boolean sandboxId: string } type PreparedWorkerIdentity = WorkerIdentity & { activeTimeoutMs: number cleanupOnFailure: boolean + inspectBeforeStart: boolean sandboxId: string } type PreparedDevinWorker = { cleanupOnFailure: boolean + inspectBeforeStart: boolean sandboxId: string } @@ -66,12 +69,17 @@ export async function launchDevinWorker( const activeTimeoutMs = await getActiveWorkerTimeoutMs(input) const existingSandboxId = await findWorkerSandbox(input) const prepared: PreparedDevinWorker = existingSandboxId - ? { cleanupOnFailure: false, sandboxId: existingSandboxId } + ? { + cleanupOnFailure: false, + inspectBeforeStart: true, + sandboxId: existingSandboxId, + } : await prepareDevinWorkerSandbox(input) return startPreparedDevinWorker({ ...input, activeTimeoutMs, cleanupOnFailure: prepared.cleanupOnFailure, + inspectBeforeStart: prepared.inspectBeforeStart, sandboxId: prepared.sandboxId, }) } @@ -109,11 +117,19 @@ async function prepareDevinWorkerSandbox( ): Promise { try { const sandbox = await createWorkerSandbox(input) - return { cleanupOnFailure: true, sandboxId: sandbox.sandboxID } + return { + cleanupOnFailure: true, + inspectBeforeStart: false, + sandboxId: sandbox.sandboxID, + } } catch (error) { const recoveredSandboxId = await recoverCreatedWorkerSandbox(input) if (recoveredSandboxId) { - return { cleanupOnFailure: false, sandboxId: recoveredSandboxId } + return { + cleanupOnFailure: true, + inspectBeforeStart: true, + sandboxId: recoveredSandboxId, + } } l.warn( @@ -162,7 +178,7 @@ async function persistPreparedDevinConnection( async function startPreparedDevinWorker( input: StartPreparedDevinWorkerInput ): Promise { - if (!input.cleanupOnFailure) { + if (input.inspectBeforeStart) { try { const sandbox = await connectWorkerSandbox( input, diff --git a/tests/unit/devin-outposts-worker.test.ts b/tests/unit/devin-outposts-worker.test.ts index d096ec1f8..357fbbfae 100644 --- a/tests/unit/devin-outposts-worker.test.ts +++ b/tests/unit/devin-outposts-worker.test.ts @@ -235,6 +235,37 @@ describe('Devin worker launcher', () => { expect(mocks.infraDelete).not.toHaveBeenCalled() }) + it('cleans a recovered stopped sandbox when setup fails', async () => { + mocks.infraGet + .mockResolvedValueOnce(apiResult(200, [])) + .mockResolvedValueOnce( + apiResult(200, [sandboxApiResponse('recovered-sbx')]) + ) + mocks.infraPost.mockImplementation((path: string) => { + if (path === '/sandboxes') { + return Promise.reject(new Error('create response timed out')) + } + if (path === '/sandboxes/{sandboxID}/connect') { + return apiResult(200, sandboxApiResponse('recovered-sbx')) + } + throw new Error(`unexpected path ${path}`) + }) + mocks.runtime = sandboxWithResults([ + { exitCode: 0, stdout: 'stopped' }, + { exitCode: 1, stdout: '' }, + ]) + + await expect(launchDevinWorker(input)).rejects.toBeInstanceOf( + DevinWorkerLaunchError + ) + expect(mocks.infraDelete).toHaveBeenCalledWith( + '/sandboxes/{sandboxID}', + expect.objectContaining({ + params: { path: { sandboxID: 'recovered-sbx' } }, + }) + ) + }) + it('keeps the scoped credential out of metadata and command text', async () => { const sandbox = sandboxWithResults([ { exitCode: 0, stdout: '' }, From 86370be90e4b0f27fe8bcff7e3b4b6e539576e2e Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 17:45:48 -0700 Subject: [PATCH 11/16] Call the browser UUID generator safely Keep the Crypto receiver intact when generating launch operation IDs, and cover the default browser path with a brand-checking implementation. --- .../connections/devin-launch-attempt.ts | 2 +- tests/unit/devin-launch-attempt.test.ts | 21 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/connections/devin-launch-attempt.ts b/src/features/dashboard/connections/devin-launch-attempt.ts index cf26a0991..d187ce75b 100644 --- a/src/features/dashboard/connections/devin-launch-attempt.ts +++ b/src/features/dashboard/connections/devin-launch-attempt.ts @@ -12,7 +12,7 @@ export type DevinLaunchAttempt = { export async function getDevinLaunchAttempt( previous: DevinLaunchAttempt | null, payload: DevinLaunchPayload, - createOperationId: () => string = crypto.randomUUID + createOperationId: () => string = () => crypto.randomUUID() ) { const fingerprint = await launchFingerprint(payload) if (previous?.fingerprint === fingerprint) return previous diff --git a/tests/unit/devin-launch-attempt.test.ts b/tests/unit/devin-launch-attempt.test.ts index ecc61eaa2..451dbf990 100644 --- a/tests/unit/devin-launch-attempt.test.ts +++ b/tests/unit/devin-launch-attempt.test.ts @@ -1,11 +1,15 @@ import { webcrypto } from 'node:crypto' -import { beforeAll, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { getDevinLaunchAttempt } from '@/features/dashboard/connections/devin-launch-attempt' beforeAll(() => { vi.stubGlobal('crypto', webcrypto) }) +afterEach(() => { + vi.stubGlobal('crypto', webcrypto) +}) + const payload = { apiUrl: 'https://api.devin.ai', outpostsToken: 'scoped-token', @@ -13,6 +17,21 @@ const payload = { } describe('Devin launch attempt identity', () => { + it('calls the browser UUID generator with its Crypto receiver', async () => { + const brandedCrypto = { + randomUUID() { + if (this !== brandedCrypto) throw new TypeError('Illegal invocation') + return 'operation-1' + }, + subtle: webcrypto.subtle, + } + vi.stubGlobal('crypto', brandedCrypto) + + await expect(getDevinLaunchAttempt(null, payload)).resolves.toMatchObject({ + operationId: 'operation-1', + }) + }) + it('reuses the operation ID only for the exact launch payload', async () => { const createOperationId = vi .fn() From 8222e00b636b5e43be70e630c4fe9e0664a6ab9d Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 17:46:53 -0700 Subject: [PATCH 12/16] Test installed tRPC credential sanitizers Use one sanitized console adapter in the real logger link and assert both browser console methods plus the server failure sink keep Devin credentials out of logs. --- src/trpc/client.tsx | 7 ++-- src/trpc/sanitize-logger-args.ts | 7 +++- tests/unit/connections-router.test.ts | 15 +++++++++ tests/unit/trpc-logger-input.test.ts | 47 +++++++++++++++------------ 4 files changed, 49 insertions(+), 27 deletions(-) diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx index f5f5c5ce0..77f62e642 100644 --- a/src/trpc/client.tsx +++ b/src/trpc/client.tsx @@ -10,7 +10,7 @@ import { useState } from 'react' import SuperJSON from 'superjson' import type { TRPCAppRouter } from '@/core/server/api/routers' import { createQueryClient } from './query-client' -import { sanitizeTRPCLoggerArgs } from './sanitize-logger-args' +import { sanitizedTRPCConsole } from './sanitize-logger-args' export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext() @@ -67,10 +67,7 @@ export function TRPCReactProvider( createTRPCClient({ links: [ loggerLink({ - console: { - error: (...args) => console.error(...sanitizeTRPCLoggerArgs(args)), - log: (...args) => console.log(...sanitizeTRPCLoggerArgs(args)), - }, + console: sanitizedTRPCConsole, enabled: (opts) => (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') || diff --git a/src/trpc/sanitize-logger-args.ts b/src/trpc/sanitize-logger-args.ts index a9f52efc1..c591160a5 100644 --- a/src/trpc/sanitize-logger-args.ts +++ b/src/trpc/sanitize-logger-args.ts @@ -1,6 +1,11 @@ import { sanitizeClientInput } from '@/core/shared/observability/sanitize-input' -export function sanitizeTRPCLoggerArgs(args: unknown[]) { +export const sanitizedTRPCConsole = { + error: (...args: unknown[]) => console.error(...sanitizeTRPCLoggerArgs(args)), + log: (...args: unknown[]) => console.log(...sanitizeTRPCLoggerArgs(args)), +} + +function sanitizeTRPCLoggerArgs(args: unknown[]) { return args.map((argument) => { if ( !argument || diff --git a/tests/unit/connections-router.test.ts b/tests/unit/connections-router.test.ts index 055ed9090..a10b3ce3c 100644 --- a/tests/unit/connections-router.test.ts +++ b/tests/unit/connections-router.test.ts @@ -53,6 +53,7 @@ const { connectionsRouter } = await import( const createCaller = createCallerFactory(connectionsRouter) const infoSpy = vi.spyOn(l, 'info').mockImplementation(() => undefined) +const warnSpy = vi.spyOn(l, 'warn').mockImplementation(() => undefined) const input = { teamSlug: 'customer-team', apiUrl: 'https://api.devin.ai', @@ -162,5 +163,19 @@ describe('connectionsRouter.launchDevinWorker', () => { teamSlug: input.teamSlug, }) ).rejects.toMatchObject({ code }) + + if (kind === 'credentials') { + const failureLog = warnSpy.mock.calls.find( + ([context]) => context.key === 'trpc:procedure_failure' + )?.[0] + expect(failureLog).toMatchObject({ + 'trpc.procedure.input': { + _apiKey: 'string(16)', + _apiUrl: 'string(20)', + teamSlug: 'customer-team', + }, + }) + expect(JSON.stringify(failureLog)).not.toContain('service-user-key') + } }) }) diff --git a/tests/unit/trpc-logger-input.test.ts b/tests/unit/trpc-logger-input.test.ts index 6c3cd9634..a908eeac5 100644 --- a/tests/unit/trpc-logger-input.test.ts +++ b/tests/unit/trpc-logger-input.test.ts @@ -1,31 +1,36 @@ -import { describe, expect, it } from 'vitest' -import { sanitizeTRPCLoggerArgs } from '@/trpc/sanitize-logger-args' +import { describe, expect, it, vi } from 'vitest' +import { sanitizedTRPCConsole } from '@/trpc/sanitize-logger-args' describe('tRPC browser logger input', () => { it.each([ - 'up', - 'down', - ])('sanitizes %s logger payloads at the console sink', (direction) => { - const args = sanitizeTRPCLoggerArgs([ - direction, - { - input: { - apiKey: 'broad-secret', - outpostsToken: 'scoped-secret', - teamSlug: 'example-team', - }, - result: direction === 'down' ? new Error('failed') : undefined, - }, - ]) + 'error', + 'log', + ] as const)('sanitizes the installed console.%s adapter', (method) => { + const consoleSpy = vi + .spyOn(console, method) + .mockImplementation(() => undefined) - expect(args[1]).toMatchObject({ + sanitizedTRPCConsole[method](method === 'log' ? 'up' : 'down', { input: { - _apiKey: 'string(12)', - _outpostsToken: 'string(13)', + apiKey: 'broad-secret', + outpostsToken: 'scoped-secret', teamSlug: 'example-team', }, + result: method === 'error' ? new Error('failed') : undefined, }) - expect(JSON.stringify(args)).not.toContain('broad-secret') - expect(JSON.stringify(args)).not.toContain('scoped-secret') + + expect(consoleSpy).toHaveBeenCalledWith( + method === 'log' ? 'up' : 'down', + expect.objectContaining({ + input: { + _apiKey: 'string(12)', + _outpostsToken: 'string(13)', + teamSlug: 'example-team', + }, + }) + ) + expect(JSON.stringify(consoleSpy.mock.calls)).not.toContain('broad-secret') + expect(JSON.stringify(consoleSpy.mock.calls)).not.toContain('scoped-secret') + consoleSpy.mockRestore() }) }) From 7e40ab75351787ebf42df4ebc11b9f2b562fc869 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 17:47:54 -0700 Subject: [PATCH 13/16] Clean recovered workers after inspection errors Route inspection failures through ownership-aware cleanup for sandboxes recovered from an ambiguous create, while continuing to preserve genuinely pre-existing workers. --- .../modules/devin-outposts/worker.server.ts | 1 + tests/unit/devin-outposts-worker.test.ts | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/core/modules/devin-outposts/worker.server.ts b/src/core/modules/devin-outposts/worker.server.ts index d78aaba35..31f2aeba3 100644 --- a/src/core/modules/devin-outposts/worker.server.ts +++ b/src/core/modules/devin-outposts/worker.server.ts @@ -188,6 +188,7 @@ async function startPreparedDevinWorker( return reusedWorkerResult(input) } } catch { + if (input.cleanupOnFailure) return failWorkerLaunch(input) throw new DevinWorkerLaunchError() } } diff --git a/tests/unit/devin-outposts-worker.test.ts b/tests/unit/devin-outposts-worker.test.ts index 357fbbfae..5019da537 100644 --- a/tests/unit/devin-outposts-worker.test.ts +++ b/tests/unit/devin-outposts-worker.test.ts @@ -266,6 +266,33 @@ describe('Devin worker launcher', () => { ) }) + it('cleans a recovered sandbox when its inspection fails', async () => { + mocks.infraGet + .mockResolvedValueOnce(apiResult(200, [])) + .mockResolvedValueOnce( + apiResult(200, [sandboxApiResponse('recovered-sbx')]) + ) + mocks.infraPost.mockImplementation((path: string) => { + if (path === '/sandboxes') { + return Promise.reject(new Error('create response timed out')) + } + if (path === '/sandboxes/{sandboxID}/connect') { + return Promise.reject(new Error('connect failed')) + } + throw new Error(`unexpected path ${path}`) + }) + + await expect(launchDevinWorker(input)).rejects.toBeInstanceOf( + DevinWorkerLaunchError + ) + expect(mocks.infraDelete).toHaveBeenCalledWith( + '/sandboxes/{sandboxID}', + expect.objectContaining({ + params: { path: { sandboxID: 'recovered-sbx' } }, + }) + ) + }) + it('keeps the scoped credential out of metadata and command text', async () => { const sandbox = sandboxWithResults([ { exitCode: 0, stdout: '' }, From 7d4a77f974e24635771011a122a232e3b753daf1 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 19:01:54 -0700 Subject: [PATCH 14/16] Default Devin connections to enterprise beta --- .../dashboard/connections/devin-connection-form.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/features/dashboard/connections/devin-connection-form.tsx b/src/features/dashboard/connections/devin-connection-form.tsx index 1e42f8608..bca047c68 100644 --- a/src/features/dashboard/connections/devin-connection-form.tsx +++ b/src/features/dashboard/connections/devin-connection-form.tsx @@ -33,6 +33,8 @@ import { type Pool = { id: string; name: string; platform: string } +const DEFAULT_DEVIN_API_URL = 'https://api.beta.devinenterprise.com' + type ManualConnectionState = { accountChecked: boolean apiKey: string @@ -216,7 +218,7 @@ function ManualDevinConnection({ try { const data = await trpcClient.connections.discoverDevin.mutate({ apiKey: submittedApiKey, - apiUrl: apiUrlRef.current?.value || 'https://api.devin.ai', + apiUrl: apiUrlRef.current?.value || DEFAULT_DEVIN_API_URL, teamSlug, }) if (!mounted.current) return @@ -244,7 +246,7 @@ function ManualDevinConnection({ event.preventDefault() if (launchDisabledReason || pendingOperation) return const launchPayload = { - apiUrl: apiUrlRef.current?.value || 'https://api.devin.ai', + apiUrl: apiUrlRef.current?.value || DEFAULT_DEVIN_API_URL, outpostsToken, poolId, } @@ -331,10 +333,10 @@ function ManualDevinConnection({ id="devin-api-url" autoComplete="off" disabled={anyOperationPending} - defaultValue="https://api.devin.ai" + defaultValue={DEFAULT_DEVIN_API_URL} onChange={resetDiscovery} ref={apiUrlRef} - placeholder="https://api.devin.ai" + placeholder={DEFAULT_DEVIN_API_URL} /> From b9714f07e4e58d5500a3aada16a89965b3fbc9cd Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 15 Jul 2026 20:19:18 -0700 Subject: [PATCH 15/16] Create Devin Outposts pools from Connections Keep the service credential in page memory only, and reconcile ambiguous provider responses before allowing a retry. --- .../modules/devin-outposts/client.server.ts | 100 +++- src/core/server/api/routers/connections.ts | 79 ++- .../connections/devin-connection-form.tsx | 532 +++++++++++++----- tests/unit/connections-router.test.ts | 93 ++- tests/unit/devin-outposts-client.test.ts | 173 ++++++ 5 files changed, 818 insertions(+), 159 deletions(-) diff --git a/src/core/modules/devin-outposts/client.server.ts b/src/core/modules/devin-outposts/client.server.ts index 3ac1bb13f..30e2dacd5 100644 --- a/src/core/modules/devin-outposts/client.server.ts +++ b/src/core/modules/devin-outposts/client.server.ts @@ -16,7 +16,8 @@ export type DevinDiscovery = { export class DevinConnectionError extends Error { constructor( message: string, - readonly kind: 'credentials' | 'provider' | 'response' | 'url' + readonly kind: 'credentials' | 'provider' | 'response' | 'url', + readonly status?: number ) { super(message) } @@ -64,27 +65,84 @@ export async function discoverDevinAccount( return { pools } } +export async function createDevinPool( + apiUrlInput: string, + apiKey: string, + input: { name: string; description: string } +) { + const apiUrl = normalizeDevinApiUrl(apiUrlInput) + const credentials = { apiKey, apiUrl } + let payload: Record + + try { + payload = await devinRequest(credentials, '/opbeta/outposts/pools', { + body: JSON.stringify({ + description: input.description, + name: input.name, + platform: 'linux', + }), + method: 'POST', + }) + } catch (error) { + if ( + error instanceof DevinConnectionError && + error.kind === 'provider' && + (!error.status || error.status >= 500) + ) { + const matchingPool = await reconcileCreatedPool(credentials, input.name) + if (matchingPool) return matchingPool + throw new DevinConnectionError( + 'Could not confirm whether Devin created the pool. Check the account before retrying.', + 'provider' + ) + } + throw error + } + + const pool = poolFromPayload(payload) + if (!pool) { + const matchingPool = await reconcileCreatedPool(credentials, input.name) + if (matchingPool) return matchingPool + throw new DevinConnectionError( + 'Devin accepted the pool request but returned an unexpected response. Check the account before retrying.', + 'response' + ) + } + return pool +} + +async function reconcileCreatedPool( + credentials: { apiUrl: string; apiKey: string }, + name: string +) { + try { + return poolsFromPayload( + await devinRequest(credentials, '/opbeta/outposts/pools') + ).find((pool) => pool.name === name) + } catch { + return undefined + } +} + export function poolsFromPayload(payload: Record) { return collectionItems(payload) - .map((item): DevinPool | undefined => { - const metadata = recordField(item, 'metadata') - const spec = recordField(item, 'spec') - const id = stringField(metadata, 'pool_id') - const name = stringField(spec, 'name') - if (!id || !name) return undefined - return { id, name, platform: stringField(spec, 'platform') } - }) + .map(poolFromPayload) .filter((item): item is DevinPool => Boolean(item)) } async function devinRequest( credentials: { apiUrl: string; apiKey: string }, - path: string + path: string, + init?: Pick ) { let response: Response try { response = await fetch(`${credentials.apiUrl}${path}`, { - headers: { Authorization: `Bearer ${credentials.apiKey}` }, + headers: { + Authorization: `Bearer ${credentials.apiKey}`, + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + }, + ...init, cache: 'no-store', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) @@ -100,8 +158,15 @@ async function devinRequest( } if (!response.ok) { throw new DevinConnectionError( - 'Devin account discovery is unavailable', - 'provider' + init?.method !== 'POST' + ? 'Devin account discovery is unavailable' + : response.status === 409 + ? 'An Outposts pool with this name already exists' + : response.status === 400 || response.status === 422 + ? 'Devin rejected the Outposts pool configuration' + : 'Devin could not create the Outposts pool', + 'provider', + response.status ) } @@ -131,6 +196,15 @@ async function devinRequest( return payload as Record } +function poolFromPayload(item: Record) { + const metadata = recordField(item, 'metadata') + const spec = recordField(item, 'spec') + const id = stringField(metadata, 'pool_id') + const name = stringField(spec, 'name') + if (!id || !name) return undefined + return { id, name, platform: stringField(spec, 'platform') } +} + function records(value: unknown) { if (!Array.isArray(value)) return [] return value.filter( diff --git a/src/core/server/api/routers/connections.ts b/src/core/server/api/routers/connections.ts index 47f81fb7a..32ec92f38 100644 --- a/src/core/server/api/routers/connections.ts +++ b/src/core/server/api/routers/connections.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server' import { z } from 'zod' import { + createDevinPool, DevinConnectionError, discoverDevinAccount, normalizeDevinApiUrl, @@ -32,7 +33,73 @@ const connectionsProcedure = protectedTeamProcedure.use( } ) +function mapDevinError(error: DevinConnectionError) { + return new TRPCError({ + code: + error.kind === 'credentials' + ? 'UNAUTHORIZED' + : error.kind === 'url' + ? 'BAD_REQUEST' + : error.status === 409 + ? 'CONFLICT' + : error.status === 400 || error.status === 422 + ? 'BAD_REQUEST' + : 'BAD_GATEWAY', + message: error.message, + }) +} + export const connectionsRouter = createTRPCRouter({ + createDevinPool: connectionsProcedure + .input( + z.object({ + apiKey: z.string().trim().min(1).max(4096), + apiUrl: z.string().trim().min(1).max(512), + description: z.string().trim().max(500).optional(), + name: z + .string() + .trim() + .regex( + /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/, + 'Pool name must start with a letter or number and use only letters, numbers, dots, underscores, or hyphens' + ), + }) + ) + .mutation(async ({ input }) => { + const description = + input.description || `E2B Devin Outposts pool (${input.name})` + let apiUrl: string + try { + apiUrl = normalizeDevinApiUrl(input.apiUrl) + } catch (error) { + if (error instanceof DevinConnectionError) throw mapDevinError(error) + throw error + } + + try { + const discovered = await discoverDevinAccount(apiUrl, input.apiKey) + if (discovered.pools.some((pool) => pool.name === input.name)) { + throw new TRPCError({ + code: 'CONFLICT', + message: `An Outposts pool named ${input.name} already exists`, + }) + } + return { + pool: await createDevinPool(apiUrl, input.apiKey, { + description, + name: input.name, + }), + } + } catch (error) { + if (error instanceof TRPCError) throw error + if (error instanceof DevinConnectionError) throw mapDevinError(error) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Could not create the Devin Outposts pool', + }) + } + }), + disconnectDevinWorkers: connectionsProcedure .input(z.object({ confirm: z.literal(true) })) .mutation(async ({ ctx }) => { @@ -61,17 +128,7 @@ export const connectionsRouter = createTRPCRouter({ try { return await discoverDevinAccount(input.apiUrl, input.apiKey) } catch (error) { - if (error instanceof DevinConnectionError) { - throw new TRPCError({ - code: - error.kind === 'credentials' - ? 'UNAUTHORIZED' - : error.kind === 'url' - ? 'BAD_REQUEST' - : 'BAD_GATEWAY', - message: error.message, - }) - } + if (error instanceof DevinConnectionError) throw mapDevinError(error) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Could not check the Devin account', diff --git a/src/features/dashboard/connections/devin-connection-form.tsx b/src/features/dashboard/connections/devin-connection-form.tsx index bca047c68..69f047ca0 100644 --- a/src/features/dashboard/connections/devin-connection-form.tsx +++ b/src/features/dashboard/connections/devin-connection-form.tsx @@ -12,6 +12,15 @@ import { useTRPCClient } from '@/trpc/client' import { AlertDialog } from '@/ui/alert-dialog' import { Button } from '@/ui/primitives/button' import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/primitives/dialog' +import { + AddIcon, CheckCircleIcon, ExternalLinkIcon, KeyIcon, @@ -26,6 +35,7 @@ import { SelectTrigger, SelectValue, } from '@/ui/primitives/select' +import { Textarea } from '@/ui/primitives/textarea' import { type DevinLaunchAttempt, getDevinLaunchAttempt, @@ -55,7 +65,12 @@ type DevinConnectionFormProps = { teamSlug: string } -type PendingOperation = 'disconnect' | 'discover' | 'launch' | null +type PendingOperation = + | 'create-pool' + | 'disconnect' + | 'discover' + | 'launch' + | null export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { const [pendingOperation, setPendingOperation] = @@ -179,6 +194,8 @@ function ManualDevinConnection({ const router = useRouter() const trpcClient = useTRPCClient() const apiUrlRef = useRef(null) + const serviceApiKeyRef = useEphemeralServiceApiKey() + const poolCreationInFlight = useRef(false) const [state, updateState] = useReducer( ( current: ManualConnectionState, @@ -189,6 +206,11 @@ function ManualDevinConnection({ const { accountChecked, apiKey, outpostsToken, poolId, pools } = state const launchAttempt = useRef(null) const mounted = useMountedRef() + const [poolDialogOpen, setPoolDialogOpen] = useState(false) + const [newPoolName, setNewPoolName] = useState('') + const [newPoolDescription, setNewPoolDescription] = useState('') + const [poolCreationError, setPoolCreationError] = useState('') + const createPoolPending = pendingOperation === 'create-pool' const discoverPending = pendingOperation === 'discover' const launchPending = pendingOperation === 'launch' const anyOperationPending = pendingOperation !== null @@ -201,6 +223,8 @@ function ManualDevinConnection({ })() function resetDiscovery() { + serviceApiKeyRef.current = '' + setPoolDialogOpen(false) updateState({ accountChecked: false, outpostsToken: '', @@ -222,6 +246,7 @@ function ManualDevinConnection({ teamSlug, }) if (!mounted.current) return + serviceApiKeyRef.current = submittedApiKey updateState({ accountChecked: true, poolId: data.pools.length === 1 ? data.pools[0]?.id || '' : '', @@ -230,6 +255,7 @@ function ManualDevinConnection({ toast(defaultSuccessToast('Devin account checked.')) } catch (error) { if (!mounted.current) return + serviceApiKeyRef.current = '' resetDiscovery() toast( defaultErrorToast( @@ -242,6 +268,63 @@ function ManualDevinConnection({ if (mounted.current) setPendingOperation(null) } + function closePoolDialog() { + setPoolDialogOpen(false) + setNewPoolName('') + setNewPoolDescription('') + setPoolCreationError('') + } + + async function createPool(event: React.FormEvent) { + event.preventDefault() + const name = newPoolName.trim() + if ( + !name || + pendingOperation || + poolCreationInFlight.current || + !serviceApiKeyRef.current + ) + return + if (pools.some((pool) => pool.name === name)) { + setPoolCreationError(`An Outposts pool named ${name} already exists.`) + return + } + + setPoolCreationError('') + poolCreationInFlight.current = true + setPendingOperation('create-pool') + try { + const data = await trpcClient.connections.createDevinPool.mutate({ + apiKey: serviceApiKeyRef.current, + apiUrl: apiUrlRef.current?.value || DEFAULT_DEVIN_API_URL, + description: newPoolDescription.trim() || undefined, + name, + teamSlug, + }) + if (mounted.current) { + updateState({ + poolId: data.pool.id, + pools: [ + ...pools.filter((pool) => pool.id !== data.pool.id), + data.pool, + ], + }) + closePoolDialog() + toast(defaultSuccessToast(`Created Outposts pool ${data.pool.name}.`)) + } + } catch (error) { + if (mounted.current) { + setPoolCreationError( + error instanceof Error && error.message + ? error.message + : 'Could not create the Devin Outposts pool.' + ) + } + } + poolCreationInFlight.current = false + if (mounted.current) setPendingOperation(null) + } + async function startWorker(event: React.FormEvent) { event.preventDefault() if (launchDisabledReason || pendingOperation) return @@ -287,72 +370,18 @@ function ManualDevinConnection({ return ( <> -
-
- -
-

Manual setup

-

- Use an existing service-user API key and scoped Outposts machine - token. The API key is sent to the dashboard server only for the - lookup, then cleared and never sent to the sandbox. -

-
-
- - - - { - updateState({ - accountChecked: false, - apiKey: event.target.value, - outpostsToken: '', - poolId: '', - pools: [], - }) - }} - placeholder="cog_..." - /> - - -
- - Advanced API settings - -
- - - -
-
- - - -
+ { + serviceApiKeyRef.current = '' + updateState(manualStateForApiKey(value)) + }} + onApiUrlChange={resetDiscovery} + onSubmit={checkAccount} + />
@@ -367,90 +396,304 @@ function ManualDevinConnection({
-
- {accountChecked ? ( -

- Devin account access verified. -

- ) : null} + { + if (createPoolPending) return + if (open) { + setPoolCreationError('') + setPoolDialogOpen(true) + } else { + closePoolDialog() + } + }} + > + + {accountChecked ? ( +

+ Devin account access verified. +

+ ) : null} + + updateState({ poolId: value })} + poolId={poolId} + pools={pools} + /> -
- - updateState({ poolId: value })} - > - - - - - {pools.map((pool) => ( - - {pool.name} - - ))} - - + type="password" + value={outpostsToken} + onChange={(event) => + updateState({ outpostsToken: event.target.value }) + } + placeholder="Token with the outposts machine scope" + /> -
- {accountChecked && pools.length === 0 ? ( -

- This account has no Outposts pools. Create one in Devin, then - check the account again. +

+ This scoped token is injected into the worker sandbox. Do not use + a broad enterprise administrator credential here.

- ) : null} - - - - updateState({ outpostsToken: event.target.value }) - } - placeholder="Token with the outposts machine scope" - /> - -

- This scoped token is injected into the worker sandbox. Do not use a - broad enterprise administrator credential here. + + + {launchDisabledReason || + 'Ready to create an E2B sandbox and start the Devin worker.'} + +

+ + +
+
+ + ) +} + +function AccountDiscoverySection({ + apiKey, + apiUrlRef, + disabled, + onApiKeyChange, + onApiUrlChange, + onSubmit, + pending, +}: { + apiKey: string + apiUrlRef: React.RefObject + disabled: boolean + onApiKeyChange: (value: string) => void + onApiUrlChange: () => void + onSubmit: (event: React.FormEvent) => void + pending: boolean +}) { + return ( +
+
+ +
+

Manual setup

+

+ Use an existing service-user API key and scoped Outposts machine + token. The API key stays only in this page session for account and + pool setup. It is cleared when the account changes or this page + closes, and is never sent to the sandbox.

+
+
+
+ + onApiKeyChange(event.target.value)} + placeholder="cog_..." + type="password" + value={apiKey} + /> + +
+ + Advanced API settings + +
+ + + +
+
+ +
+
+ ) +} +function PoolSelector({ + accountChecked, + disabled, + onChange, + poolId, + pools, +}: { + accountChecked: boolean + disabled: boolean + onChange: (value: string) => void + poolId: string + pools: Pool[] +}) { + return ( + <> +
+
+ + + +
+ - - {launchDisabledReason || - 'Ready to create an E2B sandbox and start the Devin worker.'} - - - + +
+ {accountChecked && pools.length === 0 ? ( +

+ This account has no Outposts pools. Create one to continue. +

+ ) : null} ) } +function PoolCreationDialogContent({ + description, + error, + name, + onCancel, + onDescriptionChange, + onNameChange, + onSubmit, + pending, +}: { + description: string + error: string + name: string + onCancel: () => void + onDescriptionChange: (value: string) => void + onNameChange: (value: string) => void + onSubmit: (event: React.FormEvent) => void + pending: boolean +}) { + return ( + +
+ + Create Outposts pool + + Sessions assigned to this pool run on machines connected by its + workers. + + + + onNameChange(event.target.value)} + placeholder="e2b-dev-workers" + value={name} + /> + + +