Skip to content

Commit a97622c

Browse files
fix(tui): surface remote auth startup errors (anomalyco#49016)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
1 parent 7f20943 commit a97622c

6 files changed

Lines changed: 100 additions & 3 deletions

File tree

‎packages/opencode/src/cli/cmd/tui.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ export const TuiThreadCommand = cmd({
303303
unguard?.()
304304
} catch {}
305305
}
306-
process.exit(0)
306+
process.exit()
307307
},
308308
})
309309
// scratch

‎packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
2020
ConfigErrorV1.JsonError.isInstance(error) ||
2121
ConfigErrorV1.InvalidError.isInstance(error) ||
2222
ConfigErrorV1.FrontmatterError.isInstance(error) ||
23-
ConfigErrorV1.DirectoryTypoError.isInstance(error)
23+
ConfigErrorV1.DirectoryTypoError.isInstance(error) ||
24+
ConfigErrorV1.RemoteAuthError.isInstance(error)
2425
) {
2526
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 400 }))
2627
}

‎packages/opencode/test/server/httpapi-error-middleware.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,27 @@ describe("HttpApi error middleware", () => {
8383
}),
8484
)
8585

86+
it.live("returns remote auth defects as structured client errors", () =>
87+
Effect.gen(function* () {
88+
const configError = new ConfigErrorV1.RemoteAuthError({
89+
url: "https://example.com",
90+
remote: "https://config.example.com/opencode.json",
91+
})
92+
93+
yield* HttpRouter.add("GET", "/remote-auth-error", Effect.die(configError)).pipe(
94+
Layer.provide(errorLayer),
95+
HttpRouter.serve,
96+
Layer.build,
97+
)
98+
99+
const response = yield* HttpClientRequest.get("/remote-auth-error").pipe(HttpClient.execute)
100+
const body = yield* response.json
101+
102+
expect(response.status).toBe(400)
103+
expect(body).toEqual(configError.toObject())
104+
}),
105+
)
106+
86107
it.live("does not map storage not-found defects to 404", () =>
87108
Effect.gen(function* () {
88109
yield* HttpRouter.add(

‎packages/tui/src/app.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,8 +356,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
356356
)
357357
yield* Effect.sync(() => {
358358
win32FlushInputBuffer()
359-
if (result.reason !== undefined)
359+
if (result.reason !== undefined) {
360360
process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n")
361+
process.exitCode = 1
362+
}
361363
if (result.epilogue) process.stdout.write(result.epilogue + "\n")
362364
})
363365
})

‎packages/tui/src/util/error.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ export function cliErrorMessage(input: unknown): string | undefined {
4747
const frontmatter = configData(input, "ConfigFrontmatterError")
4848
if (frontmatter) return field(frontmatter, "message") ?? ""
4949

50+
const remoteAuth = configData(input, "ConfigRemoteAuthError")
51+
if (remoteAuth) {
52+
const url = field(remoteAuth, "url")
53+
const remote = field(remoteAuth, "remote")
54+
return [
55+
`Failed to load remote config${remote ? ` from ${remote}` : ""}: the server returned a login page instead of JSON.`,
56+
"Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).",
57+
...(url ? [`Run \`opencode auth login ${url}\` to re-authenticate.`] : []),
58+
].join("\n")
59+
}
60+
5061
const invalid = configData(input, "ConfigInvalidError")
5162
if (invalid) {
5263
const path = field(invalid, "path")

‎packages/tui/test/app-lifecycle.test.tsx‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,65 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
126126
mock.restore()
127127
}
128128
})
129+
130+
test("fatal startup errors set a nonzero exit after scoped cleanup", async () => {
131+
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
132+
const core = await import("@opentui/core")
133+
await mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
134+
const events = createEventSource()
135+
const calls = createFetch((url) => {
136+
if (url.pathname === "/config")
137+
return json(
138+
{
139+
name: "ConfigRemoteAuthError",
140+
data: {
141+
url: "https://example.com",
142+
remote: "https://config.example.com/opencode.json",
143+
},
144+
},
145+
{ status: 400 },
146+
)
147+
return undefined
148+
})
149+
let disposes = 0
150+
const originalWrite = process.stderr.write.bind(process.stderr)
151+
const originalExitCode = process.exitCode ?? 0
152+
let stderr = ""
153+
154+
process.stderr.write = ((chunk: string | Uint8Array) => {
155+
stderr += String(chunk)
156+
return true
157+
}) as typeof process.stderr.write
158+
159+
try {
160+
const { run } = await import("../src/app")
161+
const task = Effect.runPromise(
162+
run({
163+
url: "http://test",
164+
directory,
165+
config: createTuiResolvedConfig({ plugin_enabled: {} }),
166+
fetch: calls.fetch,
167+
events: events.source,
168+
args: {},
169+
pluginHost: {
170+
async start() {},
171+
async dispose() {
172+
disposes++
173+
},
174+
},
175+
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
176+
)
177+
178+
await task
179+
expect(stderr).toContain("Run `opencode auth login https://example.com` to re-authenticate.")
180+
expect(stderr).not.toContain("Unexpected server error")
181+
expect(process.exitCode).toBe(1)
182+
expect(setup.renderer.isDestroyed).toBe(true)
183+
expect(disposes).toBe(1)
184+
} finally {
185+
process.stderr.write = originalWrite
186+
process.exitCode = originalExitCode
187+
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
188+
mock.restore()
189+
}
190+
})

0 commit comments

Comments
 (0)