Repository navigation
fix(auth): Ctrl+C during browser sign-in exits 130 instead of 0 #1624
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,6 +41,18 @@ const cliTry = <Value>(message: string, fn: () => PromiseLike<Value>) => | |
| catch: (error) => new CliError({ message: message + errorMessage(error) }), | ||
| }) | ||
|
|
||
| // altimate_change start — resolve a pending wait on Ctrl+C (SIGINT) instead of letting it run to timeout | ||
| const SIGNIN_CANCELED = Symbol("signin-canceled") | ||
| const untilSigint = <Value>(promise: Promise<Value>) => { | ||
| let onSigint = () => {} | ||
| const canceled = new Promise<typeof SIGNIN_CANCELED>((resolve) => { | ||
| onSigint = () => resolve(SIGNIN_CANCELED) | ||
| process.once("SIGINT", onSigint) | ||
| }) | ||
| return Promise.race([promise, canceled]).finally(() => process.off("SIGINT", onSigint)) | ||
| } | ||
| // altimate_change end | ||
|
|
||
| const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( | ||
| plugin: { auth: PluginAuth }, | ||
| provider: string, | ||
|
|
@@ -108,11 +120,14 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( | |
| if (authorize.instructions) { | ||
| yield* Prompt.log.info(authorize.instructions) | ||
| } | ||
| const spinner = Prompt.spinner() | ||
| yield* spinner.start("Waiting for authorization...") | ||
| const result = yield* cliTry("Failed to authorize: ", () => authorize.callback()) | ||
| // altimate_change start — no clack spinner here: its raw-mode stdin turns Ctrl+C into | ||
| // process.exit(0), so a canceled sign-in looked successful to `&&` chains and scripts. | ||
| yield* Prompt.log.info("Waiting for authorization... (Ctrl+C to cancel)") | ||
| const result = yield* cliTry("Failed to authorize: ", () => untilSigint(authorize.callback())) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: cancel abandons the in-flight callback, so a plugin that persists inside When SIGINT wins the race, Suggested follow-up: pass an Not blocking: it needs Ctrl+C in the sub-second gap after consent has already finished in the browser. The stored credential is the one the user approved, and nothing is lost or corrupted. |
||
| if (result === SIGNIN_CANCELED) return yield* fail("Sign-in canceled.", 130) | ||
| // altimate_change end | ||
| if (result.type === "failed") { | ||
| yield* spinner.stop("Failed to authorize", 1) | ||
| yield* Prompt.log.error("Failed to authorize") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor (pre-existing): a
Not blocking: pre-existing and acknowledged. |
||
| } | ||
| if (result.type === "success") { | ||
| const saveProvider = result.provider ?? provider | ||
|
|
@@ -133,7 +148,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( | |
| ...(result.metadata ? { metadata: result.metadata } : {}), | ||
| }) | ||
| } | ||
| yield* spinner.stop("Login successful") | ||
| yield* Prompt.log.success("Login successful") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Extract the duplicated result-persist block shared by the three auth methods The |
||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -286,13 +286,15 @@ try { | |
| await cli.parse() | ||
| } | ||
| } catch (e) { | ||
| // altimate_change start — set the default before FormatError so a CliError's own exitCode (e.g. 130) wins | ||
| process.exitCode = 1 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: The new global exit-code rule also re-codes Setting the default before
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: this reorder also changes Only two callers pass a non-default exit code: Not blocking: the gap is test-only, and the behaviour change is intended. |
||
| const formatted = FormatError(e) | ||
| // altimate_change end | ||
| if (formatted) UI.error(formatted) | ||
| if (formatted === undefined) { | ||
| UI.error("Unexpected error" + EOL) | ||
| process.stderr.write(errorMessage(e) + EOL) | ||
| } | ||
| process.exitCode = 1 | ||
| } finally { | ||
| // altimate_change start - telemetry flush | ||
| // Flush any buffered telemetry events before exiting. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| // Ctrl+C while `auth login` waits for a browser sign-in must exit 130, not 0. | ||
| // | ||
| // The wait used a clack spinner. In a terminal its raw-mode stdin turned Ctrl+C into | ||
| // `process.exit(0)`, so `auth login ... && altimate-code link` carried on after a canceled | ||
| // sign-in. Without a terminal its SIGINT listener swallowed the signal and the process kept | ||
| // waiting until the sign-in timed out. | ||
| import { afterAll, beforeAll, describe, expect, test } from "bun:test" | ||
| import fs from "node:fs/promises" | ||
| import os from "node:os" | ||
| import path from "node:path" | ||
| import { spawn as ptySpawn } from "@opencode-ai/core/pty/pty.bun" | ||
|
|
||
| const cliEntry = path.resolve(import.meta.dir, "../../src/index.ts") | ||
| const argv = ["run", "--conditions=browser", cliEntry, "auth", "login", "-p", "fake-oauth"] | ||
| const WAITING = "Waiting for authorization" | ||
|
|
||
| // An OAuth "auto" method whose browser callback never completes. | ||
| const fakeAuthPlugin = `export const FakeAuth = async () => ({ | ||
| auth: { | ||
| provider: "fake-oauth", | ||
| methods: [ | ||
| { | ||
| type: "oauth", | ||
| label: "Fake browser sign-in", | ||
| authorize: async () => ({ | ||
| url: "https://example.test/authorize", | ||
| method: "auto", | ||
| callback: () => new Promise(() => {}), | ||
| }), | ||
| }, | ||
| ], | ||
| }, | ||
| }) | ||
| ` | ||
|
|
||
| let root: string | ||
| let env: Record<string, string> | ||
|
|
||
| beforeAll(async () => { | ||
| root = await fs.mkdtemp(path.join(os.tmpdir(), "auth-login-cancel-")) | ||
| const pluginPath = path.join(root, "fake-auth.ts") | ||
| await fs.writeFile(pluginPath, fakeAuthPlugin) | ||
| env = { | ||
| ...(process.env as Record<string, string>), | ||
| OPENCODE_TEST_HOME: root, | ||
| HOME: root, | ||
| XDG_CONFIG_HOME: path.join(root, ".config"), | ||
| XDG_DATA_HOME: path.join(root, ".local/share"), | ||
| XDG_STATE_HOME: path.join(root, ".local/state"), | ||
| XDG_CACHE_HOME: path.join(root, ".cache"), | ||
| OPENCODE_PURE: "1", | ||
| OPENCODE_DISABLE_AUTOUPDATE: "1", | ||
| OPENCODE_DISABLE_MODELS_FETCH: "1", | ||
| OPENCODE_DISABLE_PROJECT_CONFIG: "1", | ||
| OPENCODE_DISABLE_TELEMETRY: "1", | ||
| OPENCODE_AUTH_CONTENT: "{}", | ||
| OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: [`file://${pluginPath}`] }), | ||
| } | ||
| }) | ||
|
|
||
| afterAll(async () => { | ||
| await fs.rm(root, { recursive: true, force: true }) | ||
| }) | ||
|
|
||
| // Fails with `what()` evaluated at timeout, so the message carries the output captured so far. | ||
| async function waitFor(check: () => boolean, ms: number, what: () => string) { | ||
| const deadline = Date.now() + ms | ||
| while (!check()) { | ||
| if (Date.now() > deadline) throw new Error(`${what()} (after ${ms}ms)`) | ||
| await Bun.sleep(50) | ||
| } | ||
| } | ||
|
|
||
| async function exitWithin<T>(exited: Promise<T>, ms: number, what: string) { | ||
| let timer: ReturnType<typeof setTimeout> | undefined | ||
| const timeout = new Promise<never>((_, reject) => { | ||
| timer = setTimeout(() => reject(new Error(`${what} after ${ms}ms`)), ms) | ||
| }) | ||
| return Promise.race([exited, timeout]).finally(() => clearTimeout(timer)) | ||
| } | ||
|
|
||
| // Spawns the real CLI, so like cliIt tests it runs only in CI's bounded subprocess pass. | ||
| // Windows has no POSIX signals or Ctrl+C-as-SIGINT through a PTY. | ||
| describe.skipIf(!!process.env.OPENCODE_SKIP_SUBPROCESS || process.platform === "win32")("auth login cancel", () => { | ||
| test("Ctrl+C in a terminal exits 130", async () => { | ||
| const child = ptySpawn(process.execPath, argv, { name: "xterm-256color", cols: 120, rows: 40, cwd: root, env }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: This PTY setup duplicates Spawn + |
||
| let output = "" | ||
| child.onData((data) => { | ||
| output += data.toString() | ||
| }) | ||
| const exited = new Promise<{ exitCode: number }>((resolve) => child.onExit(resolve)) | ||
| try { | ||
| await waitFor( | ||
| () => output.includes(WAITING), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wait for the SIGINT listener before signaling.
Publish a readiness marker after listener registration and wait for it in both tests. For example, the fake callback can schedule a marker with As per coding guidelines, wait on a “published readiness signal,” not timing. Also applies to: 123-123
|
||
| 60_000, | ||
| () => `never printed "${WAITING}": ${output}`, | ||
| ) | ||
| child.write("\x03") | ||
| const { exitCode } = await exitWithin(exited, 10_000, "still running after Ctrl+C") | ||
| expect(exitCode).toBe(130) | ||
| expect(output).toContain("Sign-in canceled.") | ||
| } finally { | ||
| child.kill() | ||
| } | ||
| }, 90_000) | ||
|
|
||
| test("SIGINT without a terminal exits 130 instead of waiting for the timeout", async () => { | ||
| const proc = Bun.spawn([process.execPath, ...argv], { | ||
| cwd: root, | ||
| env, | ||
| stdin: "ignore", | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }) | ||
| let output = "" | ||
| const decoder = new TextDecoder() | ||
| const drained = (async () => { | ||
| const reader = proc.stdout.getReader() | ||
| for (let next = await reader.read(); !next.done; next = await reader.read()) output += decoder.decode(next.value) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: If the CLI exits before printing the waiting prompt, this test now sits for the full 60s before failing. The removed Prompt for AI agents |
||
| })() | ||
| try { | ||
| await waitFor( | ||
| () => output.includes(WAITING), | ||
| 60_000, | ||
| () => `never printed "${WAITING}": ${output}`, | ||
| ) | ||
| proc.kill("SIGINT") | ||
| expect(await exitWithin(proc.exited, 10_000, "still running after SIGINT")).toBe(130) | ||
| expect(await new Response(proc.stderr).text()).toContain("Sign-in canceled.") | ||
| } finally { | ||
| proc.kill("SIGKILL") | ||
| await drained | ||
| } | ||
| }, 90_000) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2:
Promise.racestops waiting but leavesauthorize.callback()running, so a browser response can still exchange tokens and persist credentials after the CLI reports cancellation. Propagate cancellation into the callback or prevent its post-cancel side effects.Prompt for AI agents