fix(workspaces): surface invalid OpenCode configuration (#635)

## Summary
- validate the authenticated OpenCode process configuration before
publishing a workspace as ready
- stop and remove instances that report invalid configuration
- show localized configuration diagnostics, affected paths, validation
issues, and typo suggestions in the existing launch-error dialog

## Existing coverage
PR #195 already handles binary spawn and early-exit failures. PR #582
surfaces session-list loading failures. This change closes the remaining
case where OpenCode reports healthy while configuration-dependent
endpoints fail.

## Validation
- `bun test ./packages/server/src/workspaces/manager.test.ts`
- `node --import tsx --test packages/ui/src/lib/launch-errors.test.ts`
- server and UI typechecks
- UI build
- manual reproduction with OpenCode 1.18.5
- independent Gatekeeper reviews: PASS

Closes #508
This commit is contained in:
Pascal André 2026-08-03 10:52:27 +02:00 committed by GitHub
parent afb09d94c8
commit c16cc005f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 200 additions and 28 deletions

View file

@ -107,6 +107,7 @@ jobs:
packages/ui/src/components/session-list-visibility.test.ts
packages/ui/src/components/unified-picker-path.test.ts
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
packages/ui/src/lib/launch-errors.test.ts
packages/ui/src/lib/message-selection-position.test.ts
packages/ui/src/lib/trailing-resync.test.ts
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts

View file

@ -59,11 +59,13 @@ class ControlledRuntime {
}
function createHarness(options: {
stubReadiness?: boolean
shutdownTimeoutMs?: number
launchTimeoutMs?: number
setTimeout?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>
clearTimeout?: (timer: ReturnType<typeof setTimeout>) => void
} = {}) {
const { stubReadiness = true, ...managerOptions } = options
const eventBus = new EventBus()
const runtime = new ControlledRuntime()
const readiness = deferred<string | undefined>()
@ -79,16 +81,18 @@ function createHarness(options: {
logger: pino({ level: "silent" }),
getServerBaseUrl: () => "http://127.0.0.1:4000",
runtime,
...options,
...managerOptions,
})
;(manager as any).waitForWorkspaceReadiness = ({ signal }: { signal?: AbortSignal }) => Promise.race([
readiness.promise,
new Promise<never>((_resolve, reject) => {
const cancel = () => reject(signal?.reason)
signal?.addEventListener("abort", cancel, { once: true })
if (signal?.aborted) cancel()
}),
])
if (stubReadiness) {
;(manager as any).waitForWorkspaceReadiness = ({ signal }: { signal?: AbortSignal }) => Promise.race([
readiness.promise,
new Promise<never>((_resolve, reject) => {
const cancel = () => reject(signal?.reason)
signal?.addEventListener("abort", cancel, { once: true })
if (signal?.aborted) cancel()
}),
])
}
return { manager, runtime, readiness, started, stopped }
}
@ -102,6 +106,49 @@ async function createReady(harness: ReturnType<typeof createHarness>) {
}
describe("workspace manager lifecycle", () => {
it("rejects a healthy workspace whose OpenCode configuration is invalid", async () => {
const originalFetch = globalThis.fetch
const requests: string[] = []
const configError = JSON.stringify({
name: "ConfigInvalidError",
data: {
path: "C:\\Users\\dev\\.config\\opencode\\agents\\invalid.md",
issues: [{ path: ["tools", "bash"], message: 'Expected boolean, got "ask"' }],
},
})
globalThis.fetch = (async (input: URL | RequestInfo) => {
const url = String(input)
requests.push(url)
if (url.includes("/global/health")) {
return new Response(JSON.stringify({ healthy: true, version: "1.18.5" }), {
headers: { "Content-Type": "application/json" },
})
}
return new Response(configError, { status: 400, headers: { "Content-Type": "application/json" } })
}) as typeof fetch
try {
const harness = createHarness({ stubReadiness: false })
;(harness.manager as any).waitForPortAvailability = async () => undefined
const creation = harness.manager.create(process.cwd())
const workspaceId = await harness.runtime.launchCalled.promise
harness.runtime.resolveLaunch()
await assert.rejects(creation, (error: unknown) => {
assert.ok(error instanceof Error)
assert.equal(error.message, configError)
return true
})
assert.deepEqual(requests.map((url) => new URL(url).pathname), ["/global/health", "/config"])
assert.equal(new URL(requests[1]).search, "")
assert.equal(harness.runtime.active.has(workspaceId), false)
assert.deepEqual(harness.started, [])
assert.deepEqual(harness.manager.list(), [])
} finally {
globalThis.fetch = originalFetch
}
})
for (const boundary of ["launch", "readiness", "shutdown"] as const) {
it(`cancels and cleans a workspace during ${boundary}`, async () => {
const harness = createHarness()

View file

@ -713,6 +713,11 @@ export class WorkspaceManager {
const version = await this.waitForInstanceHealth(params)
await Promise.race([
this.validateInstanceConfiguration(params),
this.exitDuringStartup(params, "exited during configuration validation"),
])
await Promise.race([
delay(STARTUP_STABILITY_DELAY_MS, undefined, { signal: params.signal }),
this.exitDuringStartup(params, "exited shortly after start"),
@ -753,13 +758,7 @@ export class WorkspaceManager {
const url = `http://${LOOPBACK_HOST}:${port}/global/health`
try {
const headers: Record<string, string> = {}
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, { headers, signal })
const response = await fetch(url, { headers: this.getInstanceRequestHeaders(workspaceId), signal })
if (!response.ok) {
const reason = `/global/health returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
@ -784,6 +783,25 @@ export class WorkspaceManager {
}
}
private async validateInstanceConfiguration(params: WorkspaceReadiness): Promise<void> {
const response = await fetch(`http://${LOOPBACK_HOST}:${params.port}/config`, {
headers: this.getInstanceRequestHeaders(params.workspaceId),
signal: params.signal,
})
if (response.ok) {
await response.body?.cancel()
return
}
const body = (await response.text()).trim()
throw new Error(body || `OpenCode /config returned HTTP ${response.status}`)
}
private getInstanceRequestHeaders(workspaceId: string): Record<string, string> {
const authorization = this.opencodeAuth.get(workspaceId)?.authorization
return authorization ? { Authorization: authorization } : {}
}
private buildStartupError(
workspaceId: string,
phase: string,

View file

@ -303,7 +303,11 @@ const App: Component = () => {
port: instances().get(result.instanceId)?.port,
})
} catch (error) {
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
const message = formatLaunchErrorMessage(
error,
t("app.launchError.fallbackMessage"),
t("app.launchError.invalidConfig"),
)
const missingBinary = isMissingBinaryMessage(message)
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
log.error("Failed to create instance", error)

View file

@ -1,12 +1,13 @@
export const appMessages = {
"app.launchError.title": "OpenCode konnte nicht gestartet werden",
"app.launchError.description": "Die ausgewählte OpenCode-Binärdatei konnte nicht gestartet werden. Überprüfen Sie die Fehlerausgabe unten oder wählen Sie eine andere Binärdatei in den OpenCode-Einstellungen.",
"app.launchError.description": "OpenCode konnte diesen Arbeitsbereich nicht starten. Überprüfen Sie die Fehlerausgabe unten oder Ihre OpenCode-Einstellungen.",
"app.launchError.binaryPathLabel": "Binärpfad",
"app.launchError.errorOutputLabel": "Fehlerausgabe",
"app.launchError.openAdvancedSettings": "OpenCode-Einstellungen öffnen",
"app.launchError.close": "Schließen",
"app.launchError.closeTitle": "Schließen (Esc)",
"app.launchError.fallbackMessage": "Arbeitsbereich konnte nicht gestartet werden",
"app.launchError.invalidConfig": "Die OpenCode-Konfiguration ist ungültig",
"app.stopInstance.confirmMessage": "OpenCode-Instanz stoppen? Dies wird den Server beenden.",
"app.stopInstance.title": "Instanz stoppen",

View file

@ -1,12 +1,13 @@
export const appMessages = {
"app.launchError.title": "Unable to launch OpenCode",
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from OpenCode settings.",
"app.launchError.description": "OpenCode could not start this workspace. Review the error output below or check your OpenCode settings.",
"app.launchError.binaryPathLabel": "Binary path",
"app.launchError.errorOutputLabel": "Error output",
"app.launchError.openAdvancedSettings": "Open OpenCode Settings",
"app.launchError.close": "Close",
"app.launchError.closeTitle": "Close (Esc)",
"app.launchError.fallbackMessage": "Failed to launch workspace",
"app.launchError.invalidConfig": "OpenCode configuration is invalid",
"app.stopInstance.confirmMessage": "Stop OpenCode instance? This will stop the server.",
"app.stopInstance.title": "Stop instance",

View file

@ -1,12 +1,13 @@
export const appMessages = {
"app.launchError.title": "No se pudo iniciar OpenCode",
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en la configuración de OpenCode.",
"app.launchError.description": "OpenCode no pudo iniciar este espacio de trabajo. Revisa la salida de error o la configuración de OpenCode.",
"app.launchError.binaryPathLabel": "Ruta del binario",
"app.launchError.errorOutputLabel": "Salida de error",
"app.launchError.openAdvancedSettings": "Abrir Configuración de OpenCode",
"app.launchError.close": "Cerrar",
"app.launchError.closeTitle": "Cerrar (Esc)",
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace",
"app.launchError.invalidConfig": "La configuración de OpenCode no es válida",
"app.stopInstance.confirmMessage": "¿Detener la instancia de OpenCode? Esto detendrá el servidor.",
"app.stopInstance.title": "Detener instancia",

View file

@ -1,12 +1,13 @@
export const appMessages = {
"app.launchError.title": "Impossible de lancer OpenCode",
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les paramètres OpenCode.",
"app.launchError.description": "OpenCode n'a pas pu démarrer cet espace de travail. Consultez la sortie d'erreur ci-dessous ou vérifiez vos paramètres OpenCode.",
"app.launchError.binaryPathLabel": "Chemin du binaire",
"app.launchError.errorOutputLabel": "Sortie d'erreur",
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres OpenCode",
"app.launchError.close": "Fermer",
"app.launchError.closeTitle": "Fermer (Esc)",
"app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail",
"app.launchError.invalidConfig": "La configuration OpenCode n'est pas valide",
"app.stopInstance.confirmMessage": "Arrêter l'instance OpenCode ? Cela arrêtera le serveur.",
"app.stopInstance.title": "Arrêter l'instance",

View file

@ -1,12 +1,13 @@
export const appMessages = {
"app.launchError.title": "לא ניתן להפעיל את OpenCode",
"app.launchError.description": "לא הצלחנו להפעיל את קובץ ה-OpenCode שנבחר. בדוק את פלט השגיאה למטה או בחר קובץ בינארי אחר מהגדרות OpenCode.",
"app.launchError.description": "OpenCode לא הצליח להפעיל את סביבת העבודה הזו. בדוק את פלט השגיאה למטה או את הגדרות OpenCode.",
"app.launchError.binaryPathLabel": "נתיב הקובץ הבינארי",
"app.launchError.errorOutputLabel": "פלט שגיאה",
"app.launchError.openAdvancedSettings": "פתח הגדרות OpenCode",
"app.launchError.close": "סגור",
"app.launchError.closeTitle": "סגור (Esc)",
"app.launchError.fallbackMessage": "הפעלת סביבת העבודה נכשלה",
"app.launchError.invalidConfig": "תצורת OpenCode אינה תקינה",
"app.stopInstance.confirmMessage": "לעצור את מופע OpenCode? פעולה זו תעצור את השרת.",
"app.stopInstance.title": "עצור מופע",

View file

@ -1,12 +1,13 @@
export const appMessages = {
"app.launchError.title": "OpenCode を起動できません",
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、OpenCode 設定から別のバイナリを選択してください。",
"app.launchError.description": "OpenCode はこのワークスペースを起動できませんでした。下のエラー出力または OpenCode 設定を確認してください。",
"app.launchError.binaryPathLabel": "バイナリのパス",
"app.launchError.errorOutputLabel": "エラー出力",
"app.launchError.openAdvancedSettings": "OpenCode 設定を開く",
"app.launchError.close": "閉じる",
"app.launchError.closeTitle": "閉じる (Esc)",
"app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました",
"app.launchError.invalidConfig": "OpenCode の設定が無効です",
"app.stopInstance.confirmMessage": "OpenCode インスタンスを停止しますか?サーバーが停止します。",
"app.stopInstance.title": "インスタンスを停止",

View file

@ -1,12 +1,13 @@
export const appMessages = {
"app.launchError.title": "OpenCode सुरु गर्न असमर्थ",
"app.launchError.description": "हामीले चयन गरिएको OpenCode बाइनरी सुरु गर्न सकेनौं। तलको त्रुटि आउटपुट समीक्षा गर्नुहोस् वा OpenCode सेटिङहरूबाट फरक बाइनरी छनौट गर्नुहोस्।",
"app.launchError.description": "OpenCode ले यो कार्यस्थान सुरु गर्न सकेन। तलको त्रुटि आउटपुट वा OpenCode सेटिङहरू जाँच गर्नुहोस्।",
"app.launchError.binaryPathLabel": "बाइनरी मार्ग (Binary path)",
"app.launchError.errorOutputLabel": "त्रुटि आउटपुट (Error output)",
"app.launchError.openAdvancedSettings": "OpenCode सेटिङहरू खोल्नुहोस्",
"app.launchError.close": "बन्द गर्नुहोस्",
"app.launchError.closeTitle": "बन्द गर्नुहोस् (Esc)",
"app.launchError.fallbackMessage": "कार्यस्थान सुरु गर्न असफल",
"app.launchError.invalidConfig": "OpenCode कन्फिगरेसन अमान्य छ",
"app.stopInstance.confirmMessage": "OpenCode उदाहरण रोक्ने? यसले सर्भर बन्द गर्नेछ।",
"app.stopInstance.title": "उदाहरण रोक्नुहोस्",

View file

@ -1,12 +1,13 @@
export const appMessages = {
"app.launchError.title": "Не удалось запустить OpenCode",
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в настройках OpenCode.",
"app.launchError.description": "OpenCode не удалось запустить это рабочее пространство. Проверьте вывод ошибки ниже или настройки OpenCode.",
"app.launchError.binaryPathLabel": "Путь к бинарнику",
"app.launchError.errorOutputLabel": "Вывод ошибки",
"app.launchError.openAdvancedSettings": "Открыть настройки OpenCode",
"app.launchError.close": "Закрыть",
"app.launchError.closeTitle": "Закрыть (Esc)",
"app.launchError.fallbackMessage": "Не удалось запустить рабочее пространство",
"app.launchError.invalidConfig": "Конфигурация OpenCode недействительна",
"app.stopInstance.confirmMessage": "Остановить экземпляр OpenCode? Это остановит сервер.",
"app.stopInstance.title": "Остановить экземпляр",

View file

@ -1,12 +1,13 @@
export const appMessages = {
"app.launchError.title": "无法启动 OpenCode",
"app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在 OpenCode 设置中选择其他可执行文件。",
"app.launchError.description": "OpenCode 无法启动此工作区。请查看下面的错误输出或检查 OpenCode 设置。",
"app.launchError.binaryPathLabel": "可执行文件路径",
"app.launchError.errorOutputLabel": "错误输出",
"app.launchError.openAdvancedSettings": "打开 OpenCode 设置",
"app.launchError.close": "关闭",
"app.launchError.closeTitle": "关闭 (Esc)",
"app.launchError.fallbackMessage": "启动工作区失败",
"app.launchError.invalidConfig": "OpenCode 配置无效",
"app.stopInstance.confirmMessage": "停止 OpenCode 实例?这将停止服务器。",
"app.stopInstance.title": "停止实例",

View file

@ -0,0 +1,52 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { formatLaunchErrorMessage } from "./launch-errors"
describe("formatLaunchErrorMessage", () => {
it("formats OpenCode configuration validation details", () => {
const error = new Error(JSON.stringify({
name: "ConfigInvalidError",
data: {
path: "C:\\Users\\dev\\.config\\opencode\\agents\\invalid.md",
issues: [
{ path: ["tools", "bash"], message: 'Expected boolean, got "ask"' },
{ path: ["tools", "webfetch"], message: 'Expected boolean, got "ask"' },
],
},
}))
assert.equal(formatLaunchErrorMessage(error, "fallback", "OpenCode configuration is invalid"), [
"OpenCode configuration is invalid",
"C:\\Users\\dev\\.config\\opencode\\agents\\invalid.md",
'tools.bash: Expected boolean, got "ask"',
'tools.webfetch: Expected boolean, got "ask"',
].join("\n"))
})
it("preserves message-only tagged configuration errors", () => {
const error = JSON.stringify({
_tag: "ConfigInvalidError",
path: "/home/dev/.config/opencode/opencode.json",
message: "Missing environment variable",
})
assert.equal(formatLaunchErrorMessage(error, "fallback", "Invalid configuration"), [
"Invalid configuration",
"/home/dev/.config/opencode/opencode.json",
"Missing environment variable",
].join("\n"))
})
it("preserves configuration directory typo suggestions", () => {
const error = JSON.stringify({
name: "ConfigDirectoryTypoError",
data: { dir: "/project/.opencod", suggestion: "/project/.opencode" },
})
assert.equal(formatLaunchErrorMessage(error, "fallback", "Invalid configuration"), [
"Invalid configuration",
"/project/.opencod → /project/.opencode",
].join("\n"))
})
})

View file

@ -1,4 +1,4 @@
export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string): string {
export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string, invalidConfigMessage: string): string {
if (!error) {
return fallbackMessage
}
@ -7,6 +7,8 @@ export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string
try {
const parsed = JSON.parse(raw) as unknown
const configError = formatConfigError(parsed, invalidConfigMessage)
if (configError) return configError
if (parsed && typeof parsed === "object" && "error" in parsed && typeof (parsed as any).error === "string") {
return (parsed as any).error
}
@ -17,6 +19,41 @@ export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string
return raw
}
function formatConfigError(value: unknown, invalidConfigMessage: string): string | undefined {
if (!value || typeof value !== "object") return undefined
type ConfigErrorDetails = {
path?: unknown
message?: unknown
issues?: unknown
dir?: unknown
suggestion?: unknown
}
const error = value as ConfigErrorDetails & { name?: unknown; _tag?: unknown; data?: ConfigErrorDetails }
const name = typeof error.name === "string" ? error.name : error._tag
if (!["ConfigInvalidError", "ConfigJsonError", "ConfigFrontmatterError", "ConfigDirectoryTypoError"].includes(String(name))) return undefined
const details = error.data && typeof error.data === "object" ? error.data : error
const lines = [invalidConfigMessage]
if (typeof details.path === "string" && details.path.trim()) lines.push(details.path.trim())
if (typeof details.message === "string" && details.message.trim()) lines.push(details.message.trim())
const dir = typeof details.dir === "string" ? details.dir.trim() : ""
const suggestion = typeof details.suggestion === "string" ? details.suggestion.trim() : ""
if (dir && suggestion) lines.push(`${dir}${suggestion}`)
else if (dir || suggestion) lines.push(dir || suggestion)
if (Array.isArray(details.issues)) {
for (const issue of details.issues) {
if (!issue || typeof issue !== "object") continue
const candidate = issue as { path?: unknown; message?: unknown }
const location = Array.isArray(candidate.path) ? candidate.path.map(String).join(".") : ""
const message = typeof candidate.message === "string" ? candidate.message.trim() : ""
if (location && message) lines.push(`${location}: ${message}`)
else if (message) lines.push(message)
}
}
return lines.join("\n")
}
export function isMissingBinaryMessage(message: string): boolean {
const normalized = message.toLowerCase()
return (

View file

@ -29,7 +29,11 @@ export function clearLaunchError() {
export function showWorkspaceLaunchError(workspace: WorkspaceDescriptor) {
const instanceId = workspace.id
const rawMessage = workspace.error
const message = formatLaunchErrorMessage(rawMessage, tGlobal("app.launchError.fallbackMessage"))
const message = formatLaunchErrorMessage(
rawMessage,
tGlobal("app.launchError.fallbackMessage"),
tGlobal("app.launchError.invalidConfig"),
)
const previous = lastWorkspaceErrorByInstanceId.get(instanceId)
if (previous && previous === message) {