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 (
+
+
+
+
+
+
+
+
+
+
+
+
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 (
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+ Choose worker target
+
+
+ Select the Outposts pool this worker should serve.
+
+
+
+
+
+
+
+ )
+}
+
+function Field({
+ children,
+ id,
+ label,
+}: {
+ children: React.ReactNode
+ id: string
+ label: string
+}) {
+ return (
+
+
+ {label}
+
+ {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 ? (
+
+ ) : (
+
+ Authorize in Devin
+
+
+ )}
+
+ {!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 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 (
+
+ )
+}
+
+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 ? (
+
+ ) : (
+
+ Authorize in Devin
+
+
+ )}
+
+ 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 (
-
-
-
-
-
-
-
-
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 ? (
-
- ) : (
-
- Authorize in Devin
-
-
- )}
-
- {!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 }) {
updateState({ poolId: value })}
>
@@ -400,10 +441,12 @@ 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={
-
+
Disconnect workers
@@ -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 }) {
updateState({ poolId: value })}
@@ -379,7 +425,11 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) {
@@ -397,7 +447,7 @@ function ManualDevinConnection({ teamSlug }: { teamSlug: string }) {
{
+ mounted.current = true
+ return () => {
+ mounted.current = false
+ }
+ }, [])
+ return mounted
+}
+
function Field({
children,
id,
diff --git a/src/features/dashboard/connections/devin-launch-attempt.ts b/src/features/dashboard/connections/devin-launch-attempt.ts
new file mode 100644
index 000000000..cf26a0991
--- /dev/null
+++ b/src/features/dashboard/connections/devin-launch-attempt.ts
@@ -0,0 +1,35 @@
+type DevinLaunchPayload = {
+ apiUrl: string
+ outpostsToken: string
+ poolId: string
+}
+
+export type DevinLaunchAttempt = {
+ fingerprint: string
+ operationId: string
+}
+
+export async function getDevinLaunchAttempt(
+ previous: DevinLaunchAttempt | null,
+ payload: DevinLaunchPayload,
+ createOperationId: () => string = crypto.randomUUID
+) {
+ const fingerprint = await launchFingerprint(payload)
+ if (previous?.fingerprint === fingerprint) return previous
+ return { fingerprint, operationId: createOperationId() }
+}
+
+async function launchFingerprint(payload: DevinLaunchPayload) {
+ const serialized = JSON.stringify([
+ payload.apiUrl.trim(),
+ payload.poolId.trim(),
+ payload.outpostsToken.trim(),
+ ])
+ const digest = await crypto.subtle.digest(
+ 'SHA-256',
+ new TextEncoder().encode(serialized)
+ )
+ return Array.from(new Uint8Array(digest), (byte) =>
+ byte.toString(16).padStart(2, '0')
+ ).join('')
+}
diff --git a/tests/unit/devin-launch-attempt.test.ts b/tests/unit/devin-launch-attempt.test.ts
new file mode 100644
index 000000000..ecc61eaa2
--- /dev/null
+++ b/tests/unit/devin-launch-attempt.test.ts
@@ -0,0 +1,45 @@
+import { webcrypto } from 'node:crypto'
+import { beforeAll, describe, expect, it, vi } from 'vitest'
+import { getDevinLaunchAttempt } from '@/features/dashboard/connections/devin-launch-attempt'
+
+beforeAll(() => {
+ vi.stubGlobal('crypto', webcrypto)
+})
+
+const payload = {
+ apiUrl: 'https://api.devin.ai',
+ outpostsToken: 'scoped-token',
+ poolId: 'pool-1',
+}
+
+describe('Devin launch attempt identity', () => {
+ it('reuses the operation ID only for the exact launch payload', async () => {
+ const createOperationId = vi
+ .fn()
+ .mockReturnValueOnce('operation-1')
+ .mockReturnValueOnce('operation-2')
+ const first = await getDevinLaunchAttempt(null, payload, createOperationId)
+
+ await expect(
+ getDevinLaunchAttempt(first, { ...payload }, createOperationId)
+ ).resolves.toEqual(first)
+ await expect(
+ getDevinLaunchAttempt(
+ first,
+ { ...payload, poolId: 'pool-2' },
+ createOperationId
+ )
+ ).resolves.toMatchObject({ operationId: 'operation-2' })
+ expect(createOperationId).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not retain the credential in the retry identity', async () => {
+ const attempt = await getDevinLaunchAttempt(
+ null,
+ payload,
+ () => 'operation-1'
+ )
+
+ expect(JSON.stringify(attempt)).not.toContain(payload.outpostsToken)
+ })
+})
From ab89005e57f5a34bb320827be6352dcfe735ce70 Mon Sep 17 00:00:00 2001
From: Matt Brockman
Date: Wed, 15 Jul 2026 17:41:36 -0700
Subject: [PATCH 09/16] Tighten Devin connection boundaries
Remove the unused organization lookup, make sanitizer ownership explicit, and test the actual browser/server logging and team-scoped disconnect boundaries.
---
.../modules/devin-outposts/client.server.ts | 33 +--------
src/core/server/actions/utils.ts | 2 -
.../connections/devin-connection-form.tsx | 27 ++------
src/trpc/client.tsx | 24 +------
src/trpc/sanitize-logger-args.ts | 19 ++++++
tests/unit/action-input-summary.test.ts | 11 ++-
tests/unit/connections-router.test.ts | 67 ++++++++++++++++++-
tests/unit/devin-outposts-client.test.ts | 52 +++++++-------
tests/unit/devin-outposts-worker.test.ts | 15 ++++-
tests/unit/telemetry-input.test.ts | 20 ------
tests/unit/trpc-logger-input.test.ts | 31 +++++++++
11 files changed, 173 insertions(+), 128 deletions(-)
create mode 100644 src/trpc/sanitize-logger-args.ts
delete mode 100644 tests/unit/telemetry-input.test.ts
create mode 100644 tests/unit/trpc-logger-input.test.ts
diff --git a/src/core/modules/devin-outposts/client.server.ts b/src/core/modules/devin-outposts/client.server.ts
index 4279a7ce1..3ac1bb13f 100644
--- a/src/core/modules/devin-outposts/client.server.ts
+++ b/src/core/modules/devin-outposts/client.server.ts
@@ -3,11 +3,6 @@ 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
@@ -15,7 +10,6 @@ export type DevinPool = {
}
export type DevinDiscovery = {
- organizations: DevinOrganization[]
pools: DevinPool[]
}
@@ -64,35 +58,10 @@ export async function discoverDevinAccount(
): 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))
+ return { pools }
}
export function poolsFromPayload(payload: Record) {
diff --git a/src/core/server/actions/utils.ts b/src/core/server/actions/utils.ts
index 12585e1ae..a5cb0df5a 100644
--- a/src/core/server/actions/utils.ts
+++ b/src/core/server/actions/utils.ts
@@ -1,7 +1,5 @@
import { getPublicErrorMessage } from '@/core/shared/errors'
-export { sanitizeClientInput } from '@/core/shared/observability/sanitize-input'
-
type ActionErrorOptions = {
cause?: unknown
expected?: boolean
diff --git a/src/features/dashboard/connections/devin-connection-form.tsx b/src/features/dashboard/connections/devin-connection-form.tsx
index 6f0da87c9..1e42f8608 100644
--- a/src/features/dashboard/connections/devin-connection-form.tsx
+++ b/src/features/dashboard/connections/devin-connection-form.tsx
@@ -31,13 +31,11 @@ import {
getDevinLaunchAttempt,
} from './devin-launch-attempt'
-type Organization = { id: string; name: string }
type Pool = { id: string; name: string; platform: string }
type ManualConnectionState = {
accountChecked: boolean
apiKey: string
- organizations: Organization[]
outpostsToken: string
poolId: string
pools: Pool[]
@@ -46,7 +44,6 @@ type ManualConnectionState = {
const initialManualConnectionState: ManualConnectionState = {
accountChecked: false,
apiKey: '',
- organizations: [],
outpostsToken: '',
poolId: '',
pools: [],
@@ -75,8 +72,8 @@ export function DevinConnectionForm({ teamSlug }: DevinConnectionFormProps) {
- 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({