Adds cookie-based login with a bootstrap token flow for desktop apps, secures OpenCode instance traffic with per-instance Basic auth, and updates UI/plugin clients to use credentials.
291 lines
8.8 KiB
TypeScript
291 lines
8.8 KiB
TypeScript
import { ChildProcess, spawn } from "child_process"
|
|
import { existsSync, statSync } from "fs"
|
|
import path from "path"
|
|
import { EventBus } from "../events/bus"
|
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
|
import { Logger } from "../logger"
|
|
|
|
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
|
|
|
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
|
const redacted: Record<string, string | undefined> = {}
|
|
for (const [key, value] of Object.entries(env)) {
|
|
if (value === undefined) {
|
|
redacted[key] = value
|
|
continue
|
|
}
|
|
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
|
|
}
|
|
return redacted
|
|
}
|
|
|
|
interface LaunchOptions {
|
|
workspaceId: string
|
|
folder: string
|
|
binaryPath: string
|
|
environment?: Record<string, string>
|
|
onExit?: (info: ProcessExitInfo) => void
|
|
}
|
|
|
|
export interface ProcessExitInfo {
|
|
workspaceId: string
|
|
code: number | null
|
|
signal: NodeJS.Signals | null
|
|
requested: boolean
|
|
}
|
|
|
|
interface ManagedProcess {
|
|
child: ChildProcess
|
|
requestedStop: boolean
|
|
}
|
|
|
|
export class WorkspaceRuntime {
|
|
private processes = new Map<string, ManagedProcess>()
|
|
|
|
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
|
|
|
|
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
|
this.validateFolder(options.folder)
|
|
|
|
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
|
|
|
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
|
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
|
|
exitResolve = resolveExit
|
|
})
|
|
|
|
// Store recent output for debugging - keep last 50 lines from each stream
|
|
const MAX_OUTPUT_LINES = 50
|
|
const recentStdout: string[] = []
|
|
const recentStderr: string[] = []
|
|
const getLastOutput = () => {
|
|
const combined: string[] = []
|
|
if (recentStderr.length > 0) {
|
|
combined.push("Error Stream")
|
|
combined.push(...recentStderr.slice(-10))
|
|
}
|
|
if (recentStdout.length > 0) {
|
|
combined.push("Output Stream")
|
|
combined.push(...recentStdout.slice(-10))
|
|
}
|
|
return combined.join("\n")
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const commandLine = [options.binaryPath, ...args].join(" ")
|
|
this.logger.info(
|
|
{
|
|
workspaceId: options.workspaceId,
|
|
folder: options.folder,
|
|
binary: options.binaryPath,
|
|
args,
|
|
commandLine,
|
|
env: redactEnvironment(env),
|
|
},
|
|
"Launching OpenCode process",
|
|
)
|
|
const child = spawn(options.binaryPath, args, {
|
|
cwd: options.folder,
|
|
env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
})
|
|
|
|
const managed: ManagedProcess = { child, requestedStop: false }
|
|
this.processes.set(options.workspaceId, managed)
|
|
|
|
let stdoutBuffer = ""
|
|
let stderrBuffer = ""
|
|
let portFound = false
|
|
|
|
let warningTimer: NodeJS.Timeout | null = null
|
|
|
|
const startWarningTimer = () => {
|
|
warningTimer = setInterval(() => {
|
|
this.logger.warn({ workspaceId: options.workspaceId }, "Workspace runtime has not reported a port yet")
|
|
}, 10000)
|
|
}
|
|
|
|
const stopWarningTimer = () => {
|
|
if (warningTimer) {
|
|
clearInterval(warningTimer)
|
|
warningTimer = null
|
|
}
|
|
}
|
|
|
|
startWarningTimer()
|
|
|
|
const cleanupStreams = () => {
|
|
stopWarningTimer()
|
|
child.stdout?.removeAllListeners()
|
|
child.stderr?.removeAllListeners()
|
|
}
|
|
|
|
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited")
|
|
this.processes.delete(options.workspaceId)
|
|
cleanupStreams()
|
|
child.removeListener("error", handleError)
|
|
child.removeListener("exit", handleExit)
|
|
const exitInfo: ProcessExitInfo = {
|
|
workspaceId: options.workspaceId,
|
|
code,
|
|
signal,
|
|
requested: managed.requestedStop,
|
|
}
|
|
if (exitResolve) {
|
|
exitResolve(exitInfo)
|
|
exitResolve = null
|
|
}
|
|
if (!portFound) {
|
|
const recentOutput = getLastOutput().trim()
|
|
const reason = recentOutput || stderrBuffer || `Process exited with code ${code}`
|
|
reject(new Error(reason))
|
|
} else {
|
|
options.onExit?.(exitInfo)
|
|
}
|
|
}
|
|
|
|
const handleError = (error: Error) => {
|
|
cleanupStreams()
|
|
child.removeListener("exit", handleExit)
|
|
this.processes.delete(options.workspaceId)
|
|
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
|
|
if (exitResolve) {
|
|
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
|
|
exitResolve = null
|
|
}
|
|
reject(error)
|
|
}
|
|
|
|
child.on("error", handleError)
|
|
child.on("exit", handleExit)
|
|
|
|
child.stdout?.on("data", (data: Buffer) => {
|
|
const text = data.toString()
|
|
stdoutBuffer += text
|
|
const lines = stdoutBuffer.split("\n")
|
|
stdoutBuffer = lines.pop() ?? ""
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed) continue
|
|
|
|
recentStdout.push(trimmed)
|
|
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
|
recentStdout.shift()
|
|
}
|
|
|
|
this.emitLog(options.workspaceId, "info", line)
|
|
|
|
if (!portFound) {
|
|
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
|
if (portMatch) {
|
|
portFound = true
|
|
stopWarningTimer()
|
|
child.removeListener("error", handleError)
|
|
const port = parseInt(portMatch[1], 10)
|
|
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
|
|
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
child.stderr?.on("data", (data: Buffer) => {
|
|
const text = data.toString()
|
|
stderrBuffer += text
|
|
const lines = stderrBuffer.split("\n")
|
|
stderrBuffer = lines.pop() ?? ""
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed) continue
|
|
|
|
recentStderr.push(trimmed)
|
|
if (recentStderr.length > MAX_OUTPUT_LINES) {
|
|
recentStderr.shift()
|
|
}
|
|
|
|
this.emitLog(options.workspaceId, "error", line)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
async stop(workspaceId: string): Promise<void> {
|
|
const managed = this.processes.get(workspaceId)
|
|
if (!managed) return
|
|
|
|
managed.requestedStop = true
|
|
const child = managed.child
|
|
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const cleanup = () => {
|
|
child.removeListener("exit", onExit)
|
|
child.removeListener("error", onError)
|
|
}
|
|
|
|
const onExit = () => {
|
|
cleanup()
|
|
resolve()
|
|
}
|
|
const onError = (error: Error) => {
|
|
cleanup()
|
|
reject(error)
|
|
}
|
|
|
|
const resolveIfAlreadyExited = () => {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
|
cleanup()
|
|
resolve()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
child.once("exit", onExit)
|
|
child.once("error", onError)
|
|
|
|
if (resolveIfAlreadyExited()) {
|
|
return
|
|
}
|
|
|
|
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
|
|
child.kill("SIGTERM")
|
|
setTimeout(() => {
|
|
if (!child.killed) {
|
|
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
|
|
child.kill("SIGKILL")
|
|
} else {
|
|
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
|
|
}
|
|
}, 2000)
|
|
})
|
|
}
|
|
|
|
private emitLog(workspaceId: string, level: LogLevel, message: string) {
|
|
const entry: WorkspaceLogEntry = {
|
|
workspaceId,
|
|
timestamp: new Date().toISOString(),
|
|
level,
|
|
message: message.trim(),
|
|
}
|
|
|
|
this.eventBus.publish({ type: "workspace.log", entry })
|
|
}
|
|
|
|
private validateFolder(folder: string) {
|
|
const resolved = path.resolve(folder)
|
|
if (!existsSync(resolved)) {
|
|
throw new Error(`Folder does not exist: ${resolved}`)
|
|
}
|
|
const stats = statSync(resolved)
|
|
if (!stats.isDirectory()) {
|
|
throw new Error(`Path is not a directory: ${resolved}`)
|
|
}
|
|
}
|
|
}
|