Files
deepseek-harness/packages/e2b/lsp-e2b/src/index.ts
T

359 lines
15 KiB
TypeScript

/** E2B filesystem and process backend for the harness LSP capability seam. */
import { Buffer } from 'node:buffer'
import { posix } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import {
FileType,
quoteE2BShellArg,
resolveE2BExecutable,
} from '@deepseek-ai/dsh-e2b'
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp'
import type { LspProvider, LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { LspInstance } from '@deepseek-ai/dsh-lsp-local'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { LSP_PROXY_SOURCE } from './proxy-source.ts'
import { E2BLspTransport } from './transport.ts'
export { E2BLspTransport } from './transport.ts'
/** Cordis plugin name. */
export const name = 'lsp-e2b'
/** Services required by the remote provider. */
export const inject = ['e2b', 'lsp', 'subprocess']
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
const DEFAULT_KILL_GRACE_MS = 2_000
/* jscpd:ignore-start -- Loader requires each backend to expose its own statically walkable server schema. */
/** One configured language server inside the shared E2B sandbox. */
export interface LspE2BServerConfig {
/** Remote executable, absolute or resolved on the sandbox PATH. */
command: string
/** Lowercase leading-dot extension to LSP language id. */
extensionToLanguage: Record<string, string>
/** Remote executable arguments. */
args?: string[]
/** Explicit remote environment overrides. */
env?: Record<string, string>
/** Static `initialize` options. */
initializationOptions?: unknown
/** Static answer to every `workspace/configuration` item. */
configuration?: unknown
/** Largest LSP message accepted from the server. */
maxMessageBytes?: number
/** Largest remote stderr tail retained for diagnostics. */
maxStderrBytes?: number
/** Largest remote source opened for one query. */
maxDocumentBytes?: number
/** Graceful LSP shutdown budget. */
shutdownTimeoutMs?: number
/** Request-cancel and TERM-to-KILL grace. */
killGraceMs?: number
}
/** Plugin configuration. */
export interface Config {
/** Non-empty provider-id to remote-server table. */
servers: Record<string, LspE2BServerConfig>
}
type ResolvedServerConfig = Required<LspE2BServerConfig>
const ServerConfig: z<LspE2BServerConfig> = z.object({
command: z.string().required(),
args: z.array(String).default([]),
env: z.dict(String).default({}),
extensionToLanguage: z.dict(String).required(),
initializationOptions: z.any().default(null),
configuration: z.any().default(null),
maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES),
maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES),
maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES),
shutdownTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_KILL_GRACE_MS),
})
/** Schemastery plugin configuration. */
export const Config: z<Config> = z.object({
servers: z.dict(ServerConfig).required(),
})
/* jscpd:ignore-end */
interface RemoteSource {
canonicalPath: string
text: string
}
function abortReason(signal: AbortSignal): unknown {
try {
signal.throwIfAborted()
} catch (error: unknown) {
return error
}
return new DOMException('The operation was aborted', 'AbortError')
}
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (signal === undefined) return promise
// AbortSignal permits opaque reasons, and callers observe the exact reason.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
if (signal.aborted) return Promise.reject(abortReason(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
cleanup()
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the signal's exact opaque reason.
reject(abortReason(signal))
}
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
signal.addEventListener('abort', onAbort, { once: true })
void promise.then(
(value) => { cleanup(); resolve(value) },
(error: unknown) => { cleanup(); reject(error instanceof Error ? error : new Error(String(error))) },
)
})
}
function validateServerConfig(providerId: string, config: ResolvedServerConfig): void {
if (config.command.length === 0) throw new Error(`lsp-e2b: servers.${providerId}.command must be non-empty`)
for (const name of ['maxMessageBytes', 'maxStderrBytes', 'maxDocumentBytes', 'shutdownTimeoutMs', 'killGraceMs'] as const) {
const value = config[name]
if (!Number.isSafeInteger(value) || value <= 0 || (name.endsWith('Ms') && value > MAX_TIMER_DELAY_MS)) {
throw new Error(`lsp-e2b: servers.${providerId}.${name} must be a positive safe integer${name.endsWith('Ms') ? ` no greater than ${MAX_TIMER_DELAY_MS}` : ''}`)
}
}
}
async function canonicalRemotePath(sandbox: Sandbox, path: string, signal?: AbortSignal): Promise<string> {
signal?.throwIfAborted()
const result = await sandbox.commands.run(`realpath -e -- ${quoteE2BShellArg(path)}`, signal === undefined ? {} : { signal })
signal?.throwIfAborted()
const canonical = result.stdout.trim()
if (!posix.isAbsolute(canonical) || canonical.includes('\n')) throw new Error(`remote path ${JSON.stringify(path)} did not resolve canonically`)
return canonical
}
/**
* Canonicalize and validate one workspace inside E2B.
* @param sandbox - Shared sandbox that owns the workspace.
* @param workspaceRoot - Remote workspace path supplied by the query.
* @param signal - Optional query cancellation signal.
* @returns The canonical remote directory path.
*/
export async function canonicalizeE2BWorkspace(
sandbox: Sandbox,
workspaceRoot: string,
signal?: AbortSignal,
): Promise<string> {
const canonical = await canonicalRemotePath(sandbox, workspaceRoot, signal)
const info = await sandbox.files.getInfo(canonical, signal === undefined ? {} : { signal })
signal?.throwIfAborted()
if (info.type !== FileType.DIR) throw new Error(`workspace root ${JSON.stringify(workspaceRoot)} is not a directory`)
return canonical
}
/**
* Resolve, contain, and read one UTF-8 query source inside E2B.
* @param sandbox - Shared sandbox that owns the source.
* @param filePath - Absolute path or path relative to the canonical workspace.
* @param workspace - Canonical remote workspace directory.
* @param maxDocumentBytes - Maximum source size before and after reading.
* @param signal - Optional query cancellation signal.
* @returns The canonical source path and decoded text.
*/
export async function readE2BSource(
sandbox: Sandbox,
filePath: string,
workspace: string,
maxDocumentBytes: number,
signal?: AbortSignal,
): Promise<RemoteSource> {
const requested = posix.isAbsolute(filePath) ? filePath : posix.resolve(workspace, filePath)
const canonicalPath = await canonicalRemotePath(sandbox, requested, signal)
const relative = posix.relative(workspace, canonicalPath)
if (relative === '..' || relative.startsWith('../') || posix.isAbsolute(relative)) {
throw new Error(`source ${JSON.stringify(filePath)} resolves outside the workspace`)
}
const info = await sandbox.files.getInfo(canonicalPath, signal === undefined ? {} : { signal })
if (info.type !== FileType.FILE) throw new Error(`source ${JSON.stringify(filePath)} is not a regular file`)
if (info.size > maxDocumentBytes) {
throw new Error(`source ${JSON.stringify(filePath)} is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
}
const bytes = await sandbox.files.read(canonicalPath, { format: 'bytes', ...signal === undefined ? {} : { signal } })
signal?.throwIfAborted()
if (bytes.length > maxDocumentBytes) {
throw new Error(`source ${JSON.stringify(filePath)} grew past the ${maxDocumentBytes}-byte limit while reading`)
}
let text: string
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch (error: unknown) {
throw new Error(`source ${JSON.stringify(filePath)} is not valid UTF-8 text`, { cause: error })
}
return { canonicalPath, text }
}
/* jscpd:ignore-start -- Provider identity mirrors the seam while remote source and process ownership stay local. */
/** One pooled remote provider with an isolated server per canonical workspace. */
export class E2BLspProvider implements LspProvider {
readonly id: LspProviderId
readonly extensionToLanguage: Readonly<Record<string, string>>
private readonly instances = new Map<string, LspInstance>()
private readonly queues = new Map<string, Promise<void>>()
private disposed = false
constructor(
providerId: string,
private readonly sandbox: Sandbox,
private readonly subprocess: E2BSubprocessService,
private readonly config: ResolvedServerConfig,
private readonly executable: string,
private readonly nodeExecutable: string,
private readonly proxyPath: string,
) {
this.id = LspProviderId(providerId)
this.extensionToLanguage = config.extensionToLanguage
}
/* jscpd:ignore-end */
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
this.assertActive(signal)
const workspace = await canonicalizeE2BWorkspace(this.sandbox, request.workspaceRoot, signal)
this.assertActive(signal)
return this.enqueue(workspace, signal, async () => {
this.assertActive(signal)
const source = await readE2BSource(this.sandbox, request.filePath, workspace, this.config.maxDocumentBytes, signal)
this.assertActive(signal)
let instance = this.instanceFor(workspace)
try {
return await instance.query(request, source, signal)
} catch (error: unknown) {
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evict(workspace, instance)
this.assertActive(signal)
instance = this.instanceFor(workspace)
return await instance.query(request, source, signal)
} finally {
if (instance.dead) {
await instance.dispose()
this.evict(workspace, instance)
}
}
})
}
/* jscpd:ignore-start -- Queue and pooling semantics are shared; transport failure and disposal identities differ. */
/** Stop accepting work and await every remote server and queued query. */
async disposeAll(): Promise<void> {
this.disposed = true
const instances = [...this.instances.values()]
const queues = [...this.queues.values()]
this.instances.clear()
await Promise.all([...instances.map(instance => instance.dispose()), ...queues])
this.queues.clear()
}
private assertActive(signal?: AbortSignal): void {
if (this.disposed) throw new LspError('lsp-e2b provider is disposed', 'LSP_DISPOSED')
signal?.throwIfAborted()
}
private enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
const previous = this.queues.get(workspace) ?? Promise.resolve()
const result = abortable(previous, signal).then(run)
const tail = previous.then(() => result).then(() => undefined, () => undefined)
this.queues.set(workspace, tail)
void tail.then(() => { if (this.queues.get(workspace) === tail) this.queues.delete(workspace) })
return result
}
private instanceFor(workspace: string): LspInstance {
this.assertActive()
const existing = this.instances.get(workspace)
if (existing !== undefined) return existing
const created = this.createInstance(workspace)
this.instances.set(workspace, created)
return created
}
/* jscpd:ignore-end */
private createInstance(workspace: string): LspInstance {
return new LspInstance({
command: this.executable,
args: this.config.args,
cwd: workspace,
env: this.config.env,
configuration: this.config.configuration,
initializationOptions: this.config.initializationOptions,
maxMessageBytes: this.config.maxMessageBytes,
maxStderrBytes: this.config.maxStderrBytes,
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
killGraceMs: this.config.killGraceMs,
clientProcessId: null,
}, (spec: SubprocessSpawnSpec) => {
const originalArgv = Buffer.from(JSON.stringify(spec.argv)).toString('base64')
const inner = this.subprocess.spawn({
...spec,
argv: [this.nodeExecutable, this.proxyPath, originalArgv],
stdio: {
stdin: 'pipe',
stdout: 'pipe',
stderr: { maxBytes: this.config.maxStderrBytes },
},
})
const rawBound = Math.max(this.config.maxMessageBytes, this.config.maxStderrBytes)
return new E2BLspTransport(inner, rawBound * 2 + 1024, this.config.maxStderrBytes)
})
}
private evict(workspace: string, instance: LspInstance): void {
if (this.instances.get(workspace) === instance) this.instances.delete(workspace)
}
}
/** Install the proxy, resolve remote commands, and atomically register providers. */
export async function apply(ctx: Context, config: Config): Promise<void> {
if (!(ctx.subprocess instanceof E2BSubprocessService)) {
throw new Error('lsp-e2b requires @deepseek-ai/dsh-subprocess-e2b as ctx.subprocess')
}
const subprocess = ctx.subprocess
const entries = Object.entries(config.servers)
if (entries.length === 0) throw new Error('lsp-e2b: servers must contain at least one server')
const sandbox = await ctx.e2b.getSandbox()
const proxyPath = posix.join(ctx.e2b.runtimeRoot, 'lsp-stdio-proxy.mjs')
await sandbox.files.write([{ path: proxyPath, data: LSP_PROXY_SOURCE }])
await sandbox.commands.run(`chmod 600 -- ${quoteE2BShellArg(proxyPath)}`)
const nodeExecutable = await resolveE2BExecutable(sandbox, 'node')
const providers = await Promise.all(entries.map(async ([providerId, raw]) => {
if (providerId.trim() === '') throw new Error('lsp-e2b: server ids must be non-empty strings')
const resolved = raw as ResolvedServerConfig
validateServerConfig(providerId, resolved)
const executable = await resolveE2BExecutable(sandbox, resolved.command)
return new E2BLspProvider(providerId, sandbox, subprocess, resolved, executable, nodeExecutable, proxyPath)
}))
/* jscpd:ignore-start -- Every provider table publishes atomically through the same registry contract. */
ctx.effect(() => {
const disposers: Array<() => void> = []
try {
for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider))
} catch (error: unknown) {
for (const dispose of disposers.reverse()) dispose()
throw error
}
return async () => {
for (const dispose of disposers.reverse()) dispose()
await Promise.all(providers.map(provider => provider.disposeAll()))
}
}, 'lsp-e2b.registerProviders')
/* jscpd:ignore-end */
}