diff --git a/.env.example b/.env.example index 021679ea7..6902d8251 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,10 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### OPTIONAL SERVER ENVIRONMENT VARIABLES ### ================================= +### Devin Outposts worker template. Leave unset for the public production alias; +### development environments may set a team-owned alias or tag. +# 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/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..fea75d586 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/connections/layout.tsx @@ -0,0 +1,49 @@ +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, + }, + } + ) + + 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..30e2dacd5 --- /dev/null +++ b/src/core/modules/devin-outposts/client.server.ts @@ -0,0 +1,235 @@ +import 'server-only' + +const REQUEST_TIMEOUT_MS = 15_000 +const ALLOWED_API_HOST_SUFFIXES = ['.devin.ai', '.devinenterprise.com'] + +export type DevinPool = { + id: string + name: string + platform: string +} + +export type DevinDiscovery = { + pools: DevinPool[] +} + +export class DevinConnectionError extends Error { + constructor( + message: string, + readonly kind: 'credentials' | 'provider' | 'response' | 'url', + readonly status?: number + ) { + 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 pools = poolsFromPayload( + await devinRequest(credentials, '/opbeta/outposts/pools') + ) + 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(poolFromPayload) + .filter((item): item is DevinPool => Boolean(item)) +} + +async function devinRequest( + credentials: { apiUrl: string; apiKey: string }, + path: string, + init?: Pick +) { + let response: Response + try { + response = await fetch(`${credentials.apiUrl}${path}`, { + headers: { + Authorization: `Bearer ${credentials.apiKey}`, + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + }, + ...init, + 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( + 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 + ) + } + + 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 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( + (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/modules/devin-outposts/worker.server.ts b/src/core/modules/devin-outposts/worker.server.ts new file mode 100644 index 000000000..31f2aeba3 --- /dev/null +++ b/src/core/modules/devin-outposts/worker.server.ts @@ -0,0 +1,536 @@ +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 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 +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 +} + +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 +} + +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 activeTimeoutMs = await getActiveWorkerTimeoutMs(input) + const existingSandboxId = await findWorkerSandbox(input) + const prepared: PreparedDevinWorker = existingSandboxId + ? { + cleanupOnFailure: false, + inspectBeforeStart: true, + sandboxId: existingSandboxId, + } + : await prepareDevinWorkerSandbox(input) + return startPreparedDevinWorker({ + ...input, + activeTimeoutMs, + cleanupOnFailure: prepared.cleanupOnFailure, + inspectBeforeStart: prepared.inspectBeforeStart, + sandboxId: prepared.sandboxId, + }) +} + +export async function disconnectDevinWorkers( + input: Pick +) { + const result = await listDisconnectableWorkerSandboxes(input) + const sandboxIds = result.map((sandbox) => sandbox.sandboxID) + 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, + }) + ) + ) + failed ||= deleted.some((success) => !success) + } + + if (failed) throw new DevinWorkerLaunchError() + return { count: sandboxIds.length } +} + +async function prepareDevinWorkerSandbox( + input: WorkerIdentity +): Promise { + try { + const sandbox = await createWorkerSandbox(input) + return { + cleanupOnFailure: true, + inspectBeforeStart: false, + sandboxId: sandbox.sandboxID, + } + } catch (error) { + const recoveredSandboxId = await recoverCreatedWorkerSandbox(input) + if (recoveredSandboxId) { + return { + cleanupOnFailure: true, + inspectBeforeStart: true, + sandboxId: recoveredSandboxId, + } + } + + 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() + } +} + +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) +} + +async function startPreparedDevinWorker( + input: StartPreparedDevinWorkerInput +): Promise { + if (input.inspectBeforeStart) { + try { + const sandbox = await connectWorkerSandbox( + input, + PREPARED_SANDBOX_TIMEOUT_MS + ) + if (await sandboxHasStartedWorker(sandbox, input.operationId)) { + return reusedWorkerResult(input) + } + } catch { + if (input.cleanupOnFailure) return failWorkerLaunch(input) + throw new DevinWorkerLaunchError() + } + } + + try { + await persistPreparedDevinConnection(input) + } catch { + return failWorkerLaunch(input) + } + return startPersistedDevinWorker(input) +} + +async function startPersistedDevinWorker( + input: PreparedWorkerIdentity +): 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 reusedWorkerResult(input) + } + + 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, input.activeTimeoutMs) + + return { + acceptorId, + reused: false, + sandboxId: input.sandboxId, + workerPid: result.stdout.trim(), + } + } catch { + return failWorkerLaunch(input) + } +} + +function reusedWorkerResult( + input: PreparedWorkerIdentity +): 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( + { + 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' +} + +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 findWorkerSandbox(input: WorkerIdentity) { + const sandboxes = await listWorkerSandboxes(input) + return sandboxes.find( + (sandbox) => sandbox.metadata?.devinLaunchOperationId === input.operationId + )?.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 +) { + const metadata = new URLSearchParams({ + source: 'dashboard-devin-outposts', + 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 +} + +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) { + const result = await infra.POST('/sandboxes', { + body: { + autoPause: false, + autoPauseMemory: true, + 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 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 + }, + 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/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..32ec92f38 --- /dev/null +++ b/src/core/server/api/routers/connections.ts @@ -0,0 +1,180 @@ +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { + createDevinPool, + DevinConnectionError, + discoverDevinAccount, + normalizeDevinApiUrl, +} from '@/core/modules/devin-outposts/client.server' +import { + DevinWorkerLaunchError, + 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() + } +) + +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 }) => { + 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: connectionsProcedure + .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 mapDevinError(error) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Could not check the Devin account', + }) + } + }), + + launchDevinWorker: connectionsProcedure + .input( + z.object({ + apiUrl: z.string().trim().min(1).max(512), + operationId: z.uuid(), + outpostsToken: z.string().trim().min(1).max(4096), + poolId: z.string().trim().min(1).max(256), + }) + ) + .mutation(async ({ ctx, input }) => { + try { + normalizeDevinApiUrl(input.apiUrl) + } catch (error) { + if (error instanceof DevinConnectionError) { + throw new TRPCError({ code: 'BAD_REQUEST', message: error.message }) + } + throw error + } + try { + 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, + }) + } catch (error) { + const orphanedSandboxId = + error instanceof DevinWorkerLaunchError + ? error.orphanedSandboxId + : undefined + 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', + }) + } + }), +}) 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..69f047ca0 --- /dev/null +++ b/src/features/dashboard/connections/devin-connection-form.tsx @@ -0,0 +1,749 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect, useReducer, useRef, useState } from 'react' +import { PROTECTED_URLS } from '@/configs/urls' +import { + defaultErrorToast, + defaultSuccessToast, + toast, +} from '@/lib/hooks/use-toast' +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, + LogOutIcon, + TerminalIcon, +} from '@/ui/primitives/icons' +import { Input } from '@/ui/primitives/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/ui/primitives/select' +import { Textarea } from '@/ui/primitives/textarea' +import { + type DevinLaunchAttempt, + getDevinLaunchAttempt, +} from './devin-launch-attempt' + +type Pool = { id: string; name: string; platform: string } + +const DEFAULT_DEVIN_API_URL = 'https://api.beta.devinenterprise.com' + +type ManualConnectionState = { + accountChecked: boolean + apiKey: string + outpostsToken: string + poolId: string + pools: Pool[] +} + +const initialManualConnectionState: ManualConnectionState = { + accountChecked: false, + apiKey: '', + outpostsToken: '', + poolId: '', + pools: [], +} + +type DevinConnectionFormProps = { + teamSlug: string +} + +type PendingOperation = + | 'create-pool' + | 'disconnect' + | 'discover' + | 'launch' + | null + +export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) { + const [pendingOperation, setPendingOperation] = + useState(null) + + return ( +
+
+
+
+ +
+
+

Connection

+

Devin Outposts

+
+
+

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

+
+ + + +
+ ) +} + +type OperationProps = DevinConnectionFormProps & { + pendingOperation: PendingOperation + setPendingOperation: (operation: PendingOperation) => void +} + +function DevinWorkerControls({ + pendingOperation, + setPendingOperation, + teamSlug, +}: OperationProps) { + const trpcClient = useTRPCClient() + const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false) + const mounted = useMountedRef() + const disconnectPending = pendingOperation === 'disconnect' + + async function disconnectWorkers() { + 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 + ? 'No E2B Devin workers were running.' + : `Disconnected ${data.count} E2B Devin ${data.count === 1 ? 'worker' : 'workers'}.` + ) + ) + setDisconnectDialogOpen(false) + } catch (error) { + if (!mounted.current) return + toast( + defaultErrorToast( + error instanceof Error && error.message + ? error.message + : 'Could not disconnect the Devin workers.' + ) + ) + } + if (mounted.current) setPendingOperation(null) + } + + return ( +
+

Workers

+

+ Stop every Devin worker sandbox created for your account in this E2B + team. +

+ + Disconnect workers + + + } + /> +
+ ) +} + +function ManualDevinConnection({ + pendingOperation, + setPendingOperation, + teamSlug, +}: OperationProps) { + const router = useRouter() + const trpcClient = useTRPCClient() + const apiUrlRef = useRef(null) + const serviceApiKeyRef = useEphemeralServiceApiKey() + const poolCreationInFlight = useRef(false) + const [state, updateState] = useReducer( + ( + current: ManualConnectionState, + update: Partial + ) => ({ ...current, ...update }), + initialManualConnectionState + ) + 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 + + 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 + })() + + function resetDiscovery() { + serviceApiKeyRef.current = '' + setPoolDialogOpen(false) + updateState({ + accountChecked: false, + outpostsToken: '', + poolId: '', + pools: [], + }) + } + + async function checkAccount(event: React.FormEvent) { + event.preventDefault() + if (!apiKey.trim() || pendingOperation) return + const submittedApiKey = apiKey + updateState({ apiKey: '' }) + setPendingOperation('discover') + try { + const data = await trpcClient.connections.discoverDevin.mutate({ + apiKey: submittedApiKey, + apiUrl: apiUrlRef.current?.value || DEFAULT_DEVIN_API_URL, + teamSlug, + }) + if (!mounted.current) return + serviceApiKeyRef.current = submittedApiKey + updateState({ + accountChecked: true, + poolId: data.pools.length === 1 ? data.pools[0]?.id || '' : '', + pools: data.pools, + }) + toast(defaultSuccessToast('Devin account checked.')) + } catch (error) { + if (!mounted.current) return + serviceApiKeyRef.current = '' + resetDiscovery() + toast( + defaultErrorToast( + error instanceof Error && error.message + ? error.message + : 'Could not check Devin account.' + ) + ) + } + 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 + const launchPayload = { + apiUrl: apiUrlRef.current?.value || DEFAULT_DEVIN_API_URL, + outpostsToken, + poolId, + } + setPendingOperation('launch') + updateState({ outpostsToken: '' }) + try { + launchAttempt.current = await getDevinLaunchAttempt( + launchAttempt.current, + launchPayload + ) + const data = await trpcClient.connections.launchDevinWorker.mutate({ + ...launchPayload, + operationId: launchAttempt.current.operationId, + teamSlug, + }) + if (!mounted.current) return + launchAttempt.current = null + 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) { + if (!mounted.current) return + toast( + defaultErrorToast( + error instanceof Error && error.message + ? error.message + : 'Failed to start the Devin Outposts worker.' + ) + ) + } + if (mounted.current) setPendingOperation(null) + } + + return ( + <> + { + serviceApiKeyRef.current = '' + updateState(manualStateForApiKey(value)) + }} + onApiUrlChange={resetDiscovery} + onSubmit={checkAccount} + /> + +
+
+ +
+

+ Choose worker target +

+

+ Select the Outposts pool this worker should serve. +

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

+ Devin account access verified. +

+ ) : null} + + updateState({ poolId: value })} + poolId={poolId} + pools={pools} + /> + + + + 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 ( + <> +
+
+ + + +
+ + + +
+ {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} + /> + + +