Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.4",
"@e2b/code-interpreter": "^2.3.3",
"@computesdk/e2b": "^1.7.49",
"@octokit/rest": "^22.0.1",
"@ship/contracts": "workspace:*",
"@ship/sandbox": "workspace:*",
"@ship/types": "workspace:*",
"e2b": "^2.24.0",
"hono": "^4.12.18",
"jose": "^6.1.3",
"octokit-plugin-create-pull-request": "^6.0.1",
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/durable-objects/session-agent-bindings.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { Sandbox } from '@e2b/code-interpreter'
import { createAgentExecutor, type AgentExecutor, type ErrorEvent } from '../lib/agent-executor'
import type { ComputeCommandSandbox } from '../lib/sandbox-command'
import { getRepoUrl } from './session-git-meta-store'
import type { SessionDO } from './session'

/** Initialize the legacy in-VM agent executor bound to a Session DO instance. */
export async function initializeAgentExecutor(
host: SessionDO,
sandbox: Sandbox,
sandbox: ComputeCommandSandbox,
githubToken: string,
gitUser: { name: string; email: string },
): Promise<AgentExecutor> {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/durable-objects/session-fetch-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export interface SessionFetchHost {
startTask(taskDescription: string): Promise<unknown>
handleAgentResponse(response: { summary: string; hasChanges: boolean }): Promise<void>
initializeAgentExecutor(
sandbox: import('@e2b/code-interpreter').Sandbox,
sandbox: import('../lib/sandbox-command').ComputeCommandSandbox,
githubToken: string,
gitUser: { name: string; email: string },
): Promise<unknown>
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/durable-objects/session-fetch-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,12 @@ export async function handleSessionAgentRoutes(
if (!(await host.getRepoUrl())) {
return Response.json({ error: 'Repository URL not set' }, { status: 400 })
}
const { Sandbox } = await import('../lib/e2b')
const sandbox = await Sandbox.connect(sandboxInfo.sandboxId, { apiKey: host.getE2bApiKey() })
const { requireComputeSandbox } = await import('../lib/compute-provider')
const apiKey = host.getE2bApiKey()
if (!apiKey) {
return Response.json({ error: 'E2B_API_KEY not configured' }, { status: 500 })
}
const sandbox = await requireComputeSandbox(apiKey, sandboxInfo.sandboxId)
await host.initializeAgentExecutor(sandbox, body.githubToken, body.gitUser)
return Response.json({ success: true })
} catch (error) {
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/durable-objects/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { DurableObject } from 'cloudflare:workers'
import type { Env } from '../env.d'
import { SandboxManager, type SandboxInfo } from '../lib/e2b'
import type { AgentExecutor } from '../lib/agent-executor'
import type { Sandbox } from '@e2b/code-interpreter'
import type { ComputeCommandSandbox } from '../lib/sandbox-command'
import { handleSessionFetch } from './session-fetch-handlers'
import {
initializeAgentExecutor as initializeAgentExecutorBinding,
Expand Down Expand Up @@ -313,7 +313,7 @@ export class SessionDO extends DurableObject<Env> {
}

async initializeAgentExecutor(
sandbox: Sandbox,
sandbox: ComputeCommandSandbox,
githubToken: string,
gitUser: { name: string; email: string },
): Promise<AgentExecutor> {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/lib/acp-bridge-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export async function ensureAcpBridgeReady(input: {
let port = Number.parseInt(portStr, 10)
if (!Number.isFinite(port)) port = ACP_RELAY_PORT_DEFAULT

const httpsOrigin = domainFn.call(input.sandbox, port)
const httpsOrigin = await domainFn.call(input.sandbox, port)
const healthInput = {
sandbox: input.sandbox,
cwd: input.workingDirectory,
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/lib/agent-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
* Pattern: Bridges OpenCode SDK events with Git workflow automation
*/

import type { Sandbox } from '@e2b/code-interpreter'
import type { SessionDO } from '../durable-objects/session'
import {
generateBranchName,
Expand All @@ -19,6 +18,7 @@ import {
pushBranch,
type GitUser,
} from './git-workflow'
import type { ComputeCommandSandbox } from './sandbox-command'
import { GitHubClient, parseRepoUrl } from './github'
import {
executeWithRetry,
Expand All @@ -43,7 +43,7 @@ export interface ErrorEvent {
*/
export interface AgentExecutorConfig {
sessionDO: SessionDO
sandbox: Sandbox
sandbox: ComputeCommandSandbox
githubToken: string
repoUrl: string
gitUser: GitUser
Expand Down Expand Up @@ -75,7 +75,7 @@ export interface AgentResponse {
*/
export class AgentExecutor {
private sessionDO: SessionDO
private sandbox: Sandbox
private sandbox: ComputeCommandSandbox
private githubClient: GitHubClient
private repoUrl: string
private gitUser: GitUser
Expand Down
25 changes: 13 additions & 12 deletions apps/api/src/lib/chat-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@

import { connectSandbox, type Sandbox, type SandboxState } from '@ship/sandbox'
import type { Env } from '../env.d'
import { Sandbox as E2BSandbox } from './e2b'
import { getNativeE2BSandbox, requireComputeSandbox, type ComputeSandbox } from './compute-provider'
import { cloneGitHubRepoWithStrategies, generateBranchName } from './git-workflow'
import { writeStatus, type SSEWriter } from './chat-stream-helpers'
import { runSandboxCommand } from './sandbox-command'

/** Maximum wait while the provisioner spins up a fresh sandbox. */
const SANDBOX_PROVISION_WAIT_MS = 30_000
Expand Down Expand Up @@ -69,16 +70,16 @@ export async function prepareWorkspace(input: PrepareWorkspaceInput): Promise<Pr
const sandboxId = sandboxResult.sandboxId

const repoMeta = pickRepoMeta(input.meta)
const e2b = await E2BSandbox.connect(sandboxId, { apiKey: input.env.E2B_API_KEY })
await e2b.setTimeout(10 * 60 * 1000)
const computeSandbox = await requireComputeSandbox(input.env.E2B_API_KEY, sandboxId)
await getNativeE2BSandbox(computeSandbox).setTimeout(10 * 60 * 1000)

if (repoMeta && input.meta['repo_url']) {
const branch = input.meta['current_branch'] || repoMeta.branchName || repoMeta.baseBranch
await writeStatus(input.stream, 'repo-ready', `Repository ready. Branch: ${branch}`)
}

if (repoMeta && !input.meta['repo_url']) {
const cloneResult = await cloneRepoOrError(input, e2b, repoMeta, sandboxId)
const cloneResult = await cloneRepoOrError(input, computeSandbox, repoMeta, sandboxId)
if (!cloneResult.ok) return cloneResult
}

Expand Down Expand Up @@ -174,7 +175,7 @@ function pickRepoMeta(meta: Record<string, string>): RepoMeta | undefined {

async function cloneRepoOrError(
input: PrepareWorkspaceInput,
e2b: E2BSandbox,
computeSandbox: ComputeSandbox,
repo: RepoMeta,
sandboxId: string,
): Promise<PrepareWorkspaceResult> {
Expand All @@ -196,7 +197,7 @@ async function cloneRepoOrError(
const repoUrl = `https://github.com/${repo.owner}/${repo.name}.git`

try {
await cloneGitHubRepoWithStrategies(e2b, repo.owner, repo.name, repoPath, input.githubToken, {
await cloneGitHubRepoWithStrategies(computeSandbox, repo.owner, repo.name, repoPath, input.githubToken, {
timeoutMs: 120_000,
depth: 1,
singleBranch: true,
Expand All @@ -212,10 +213,10 @@ async function cloneRepoOrError(
}
}

await runQuiet(e2b, `cd ${repoPath} && git config user.name "${escape(input.gitUser.name)}"`)
await runQuiet(e2b, `cd ${repoPath} && git config user.email "${escape(input.gitUser.email)}"`)
await runQuiet(e2b, `cd ${repoPath} && git checkout ${repo.baseBranch} || true`)
await runQuiet(e2b, `cd ${repoPath} && git checkout -b ${branchName}`)
await runQuiet(computeSandbox, `cd ${repoPath} && git config user.name "${escape(input.gitUser.name)}"`)
await runQuiet(computeSandbox, `cd ${repoPath} && git config user.email "${escape(input.gitUser.email)}"`)
await runQuiet(computeSandbox, `cd ${repoPath} && git checkout ${repo.baseBranch} || true`)
await runQuiet(computeSandbox, `cd ${repoPath} && git checkout -b ${branchName}`)

await input.stub.fetch(
new Request('https://do/meta', {
Expand Down Expand Up @@ -250,9 +251,9 @@ async function cloneRepoOrError(
}
}

async function runQuiet(e2b: E2BSandbox, command: string): Promise<void> {
async function runQuiet(computeSandbox: ComputeSandbox, command: string): Promise<void> {
try {
await e2b.commands.run(command)
await runSandboxCommand(computeSandbox, command)
} catch {
// Best-effort — ignore non-zero exits in setup steps.
}
Expand Down
69 changes: 69 additions & 0 deletions apps/api/src/lib/compute-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Compute SDK provider helpers for E2B-backed sandboxes.
*
* @packageDocumentation
*/

import { e2b, type E2BSandbox } from '@computesdk/e2b'
import { Sandbox as E2BControlPlane } from 'e2b'

/** Compute SDK provider instance configured for the E2B provider. */
export type ComputeProvider = ReturnType<typeof e2b>

/** Compute SDK sandbox wrapper returned by the E2B provider. */
export type ComputeSandbox = Awaited<ReturnType<ComputeProvider['sandbox']['create']>>

/** Sandbox create options supported by Ship's E2B template lifecycle. */
export interface ShipComputeCreateOptions {
templateId?: string
timeout?: number
envs?: Record<string, string>
metadata?: Record<string, string>
lifecycle?: {
onTimeout: 'pause' | 'kill'
}
}

const providers = new Map<string, ComputeProvider>()

/** Get a lazily initialized Compute SDK provider for an E2B API key. */
export function getComputeProvider(apiKey: string): ComputeProvider {
const existing = providers.get(apiKey)
if (existing) return existing
const provider = e2b({ apiKey })
providers.set(apiKey, provider)
return provider
}

/** Create a Compute SDK sandbox using Ship's typed option shape. */
export async function createComputeSandbox(
apiKey: string,
options: ShipComputeCreateOptions,
): Promise<ComputeSandbox> {
return getComputeProvider(apiKey).sandbox.create(options)
}

/** Connect to an existing Compute SDK sandbox, preserving native E2B connection errors. */
export async function connectComputeSandbox(apiKey: string, sandboxId: string): Promise<ComputeSandbox | null> {
await E2BControlPlane.connect(sandboxId, { apiKey })
const sandbox = await getComputeProvider(apiKey).sandbox.getById(sandboxId)
if (!sandbox) throw new Error(`Compute SDK failed to wrap connected sandbox: ${sandboxId}`)
return sandbox
}

/** Connect to an existing Compute SDK sandbox or throw a contextual error. */
export async function requireComputeSandbox(apiKey: string, sandboxId: string): Promise<ComputeSandbox> {
const sandbox = await connectComputeSandbox(apiKey, sandboxId)
if (!sandbox) throw new Error(`Sandbox not found: ${sandboxId}`)
return sandbox
}

/** Destroy a sandbox with the native E2B SDK so API errors are not swallowed. */
export async function destroyComputeSandbox(apiKey: string, sandboxId: string): Promise<void> {
await E2BControlPlane.kill(sandboxId, { apiKey })
}

/** Return the native E2B SDK instance for E2B-only APIs such as PTY and pause. */
export function getNativeE2BSandbox(sandbox: ComputeSandbox): E2BSandbox {
return sandbox.getInstance()
}
93 changes: 93 additions & 0 deletions apps/api/src/lib/e2b.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
connectComputeSandbox,
createComputeSandbox,
getNativeE2BSandbox,
requireComputeSandbox,
} from './compute-provider'
import {
buildComputeCreateOptions,
createSessionSandbox,
getSandboxStatus,
refreshSandboxTimeout,
resumeSandbox,
} from './e2b'

vi.mock('./compute-provider', () => ({
connectComputeSandbox: vi.fn(),
createComputeSandbox: vi.fn(),
destroyComputeSandbox: vi.fn(),
getNativeE2BSandbox: vi.fn((sandbox: { native: unknown }) => sandbox.native),
requireComputeSandbox: vi.fn(),
}))

describe('E2B Compute SDK lifecycle helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('maps Ship sandbox config to Compute SDK create options', () => {
expect(
buildComputeCreateOptions({
sessionId: 'session-1',
timeoutMs: 1234,
autoPause: false,
metadata: { source: 'test' },
envs: { OPENAI_API_KEY: 'sk-test' },
}),
).toEqual({
templateId: 'n1exdf9kj7gpwk6810c9',
lifecycle: { onTimeout: 'kill' },
timeout: 1234,
metadata: { sessionId: 'session-1', source: 'test' },
envs: { OPENAI_API_KEY: 'sk-test' },
})
})

it('creates sandboxes with Compute SDK options', async () => {
vi.mocked(createComputeSandbox).mockResolvedValue({ sandboxId: 'sbx-1' } as never)

await expect(createSessionSandbox('e2b_key', { sessionId: 'session-1' })).resolves.toMatchObject({
id: 'sbx-1',
status: 'active',
metadata: { sessionId: 'session-1' },
})
expect(createComputeSandbox).toHaveBeenCalledWith(
'e2b_key',
expect.objectContaining({
templateId: 'n1exdf9kj7gpwk6810c9',
lifecycle: { onTimeout: 'pause' },
timeout: 300_000,
}),
)
})

it('wraps missing reconnects in resume errors', async () => {
vi.mocked(connectComputeSandbox).mockResolvedValue(null)

await expect(resumeSandbox('e2b_key', 'missing-sbx')).rejects.toMatchObject({
code: 'RESUME_FAILED',
sandboxId: 'missing-sbx',
})
})

it('wraps missing status checks in status errors', async () => {
vi.mocked(connectComputeSandbox).mockResolvedValue(null)

await expect(getSandboxStatus('e2b_key', 'missing-sbx')).rejects.toMatchObject({
code: 'STATUS_FAILED',
sandboxId: 'missing-sbx',
})
})

it('refreshes timeouts through the native E2B instance', async () => {
const native = { setTimeout: vi.fn() }
vi.mocked(requireComputeSandbox).mockResolvedValue({ native } as never)

await refreshSandboxTimeout('e2b_key', 'sbx-1', 42_000)

expect(requireComputeSandbox).toHaveBeenCalledWith('e2b_key', 'sbx-1')
expect(getNativeE2BSandbox).toHaveBeenCalled()
expect(native.setTimeout).toHaveBeenCalledWith(42_000)
})
})
Loading
Loading