fix(tui): surface remote auth startup errors (#49016)

Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-09-14 13:24:08 -04:00
committed by GitHub
parent 7f209437b5
commit a97622c801
6 changed files with 100 additions and 3 deletions
+1 -1
View File
@@ -303,7 +303,7 @@ export const TuiThreadCommand = cmd({
unguard?.()
} catch {}
}
process.exit(0)
process.exit()
},
})
// scratch
@@ -20,7 +20,8 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
ConfigErrorV1.JsonError.isInstance(error) ||
ConfigErrorV1.InvalidError.isInstance(error) ||
ConfigErrorV1.FrontmatterError.isInstance(error) ||
ConfigErrorV1.DirectoryTypoError.isInstance(error)
ConfigErrorV1.DirectoryTypoError.isInstance(error) ||
ConfigErrorV1.RemoteAuthError.isInstance(error)
) {
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 400 }))
}
@@ -83,6 +83,27 @@ describe("HttpApi error middleware", () => {
}),
)
it.live("returns remote auth defects as structured client errors", () =>
Effect.gen(function* () {
const configError = new ConfigErrorV1.RemoteAuthError({
url: "https://example.com",
remote: "https://config.example.com/opencode.json",
})
yield* HttpRouter.add("GET", "/remote-auth-error", Effect.die(configError)).pipe(
Layer.provide(errorLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClientRequest.get("/remote-auth-error").pipe(HttpClient.execute)
const body = yield* response.json
expect(response.status).toBe(400)
expect(body).toEqual(configError.toObject())
}),
)
it.live("does not map storage not-found defects to 404", () =>
Effect.gen(function* () {
yield* HttpRouter.add(
+3 -1
View File
@@ -356,8 +356,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
)
yield* Effect.sync(() => {
win32FlushInputBuffer()
if (result.reason !== undefined)
if (result.reason !== undefined) {
process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n")
process.exitCode = 1
}
if (result.epilogue) process.stdout.write(result.epilogue + "\n")
})
})
+11
View File
@@ -47,6 +47,17 @@ export function cliErrorMessage(input: unknown): string | undefined {
const frontmatter = configData(input, "ConfigFrontmatterError")
if (frontmatter) return field(frontmatter, "message") ?? ""
const remoteAuth = configData(input, "ConfigRemoteAuthError")
if (remoteAuth) {
const url = field(remoteAuth, "url")
const remote = field(remoteAuth, "remote")
return [
`Failed to load remote config${remote ? ` from ${remote}` : ""}: the server returned a login page instead of JSON.`,
"Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).",
...(url ? [`Run \`opencode auth login ${url}\` to re-authenticate.`] : []),
].join("\n")
}
const invalid = configData(input, "ConfigInvalidError")
if (invalid) {
const path = field(invalid, "path")
+62
View File
@@ -126,3 +126,65 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
mock.restore()
}
})
test("fatal startup errors set a nonzero exit after scoped cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
await mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventSource()
const calls = createFetch((url) => {
if (url.pathname === "/config")
return json(
{
name: "ConfigRemoteAuthError",
data: {
url: "https://example.com",
remote: "https://config.example.com/opencode.json",
},
},
{ status: 400 },
)
return undefined
})
let disposes = 0
const originalWrite = process.stderr.write.bind(process.stderr)
const originalExitCode = process.exitCode ?? 0
let stderr = ""
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += String(chunk)
return true
}) as typeof process.stderr.write
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
directory,
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source,
args: {},
pluginHost: {
async start() {},
async dispose() {
disposes++
},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
)
await task
expect(stderr).toContain("Run `opencode auth login https://example.com` to re-authenticate.")
expect(stderr).not.toContain("Unexpected server error")
expect(process.exitCode).toBe(1)
expect(setup.renderer.isDestroyed).toBe(true)
expect(disposes).toBe(1)
} finally {
process.stderr.write = originalWrite
process.exitCode = originalExitCode
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
mock.restore()
}
})