-
Notifications
You must be signed in to change notification settings - Fork 605
fix(cli): emit login start URL before polling under non-TTY #1034
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 |
|---|---|---|
|
|
@@ -44,6 +44,7 @@ let codexAccounts: Array<Record<string, unknown>> = []; | |
| let oauthAccounts: Array<Record<string, unknown>> = []; | ||
| let oauthActiveId: string | null = "acct_1"; | ||
| let oauthLoginStatus: Record<string, unknown> = { loggedIn: false }; | ||
| let codexLoginStatus: Record<string, unknown> = { status: "pending" }; | ||
| let keyEntries: Array<Record<string, unknown>> = []; | ||
| let keyActiveId: string | null = "key_1"; | ||
| let logs: string[] = []; | ||
|
|
@@ -290,6 +291,10 @@ async function mockManagementApi(req: Request): Promise<Response> { | |
| return json(oauthLoginStatus); | ||
| } | ||
|
|
||
| if (req.method === "GET" && url.pathname === "/api/codex-auth/login-status") { | ||
| return json(codexLoginStatus); | ||
| } | ||
|
|
||
| return json({ error: `unhandled mock endpoint: ${req.method} ${url.pathname}` }, 404); | ||
| } | ||
|
|
||
|
|
@@ -354,6 +359,7 @@ beforeEach(() => { | |
| ]; | ||
| oauthActiveId = "acct_1"; | ||
| oauthLoginStatus = { loggedIn: false }; | ||
| codexLoginStatus = { status: "pending" }; | ||
| keyEntries = [{ | ||
| id: "key_1", | ||
| label: "personal", | ||
|
|
@@ -1281,6 +1287,58 @@ describe("ocx account CLI (issue #180 matrix)", () => { | |
|
|
||
| }); | ||
|
|
||
| describe("login announces the start URL before polling (issue #1007)", () => { | ||
| test("OAuth login writes the URL synchronously even when stdout is not a TTY", async () => { | ||
| oauthLoginStatus = { loggedIn: false }; | ||
| const chunks: string[] = []; | ||
| let seenBeforeFirstPoll = false; | ||
| let polls = 0; | ||
| const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => { | ||
| polls += 1; | ||
| if (polls === 1) seenBeforeFirstPoll = chunks.join("").includes("https://auth.example/authorize"); | ||
| }); | ||
| try { | ||
| const result = await run( | ||
| ["login", "anthropic"], | ||
| { ...defaultDeps(), stdoutImpl: (chunk: string) => chunks.push(chunk) }, | ||
| ); | ||
|
|
||
| expect(result.code).toBe(2); | ||
| expect(result.stderr).toContain("login timed out"); | ||
| expect(seenBeforeFirstPoll).toBe(true); | ||
| expect(chunks.join("")).toContain("Open this URL to sign in:\nhttps://auth.example/authorize"); | ||
| expect(chunks.join("")).toContain("Sign in, then paste the redirect URL."); | ||
|
Comment on lines
+1298
to
+1310
Contributor
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. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Assert every login-start field before polling. These tests only require the authorization URL before the first
As per path instructions, shared CLI behavior changes require focused regression coverage. 📍 Affects 1 file
🤖 Prompt for AI AgentsSource: Path instructions |
||
| } finally { | ||
| sleepSpy.mockRestore(); | ||
| } | ||
| }); | ||
|
|
||
| test("Codex login writes the URL and flow id before the first poll", async () => { | ||
| codexLoginStatus = { status: "done", email: "j***@example.com" }; | ||
| const chunks: string[] = []; | ||
| let seenBeforeFirstPoll = false; | ||
| let polls = 0; | ||
| const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => { | ||
| polls += 1; | ||
| if (polls === 1) seenBeforeFirstPoll = chunks.join("").includes("https://auth.example/authorize"); | ||
| }); | ||
| try { | ||
| const result = await run( | ||
| ["login", "openai"], | ||
| { ...defaultDeps(), stdoutImpl: (chunk: string) => chunks.push(chunk) }, | ||
| ); | ||
|
|
||
| expect(result.code).toBe(0); | ||
| expect(result.stdout).toContain("Logged in as j***@example.com."); | ||
| expect(seenBeforeFirstPoll).toBe(true); | ||
| expect(chunks.join("")).toContain("Open this URL to sign in:\nhttps://auth.example/authorize"); | ||
| expect(chunks.join("")).toContain("Flow: flow-mock"); | ||
| } finally { | ||
| sleepSpy.mockRestore(); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| test("39: a login error wins over a retained OAuth credential", async () => { | ||
| oauthLoginStatus = { | ||
| loggedIn: true, | ||
|
|
||
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.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 10501
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 2687
🌐 Web query:
Node.js fs.writeSync partial writes to pipes and Bun node:fs writeSync semantics💡 Result:
In both Node.js and Bun, the fs.writeSync method does not guarantee that the entire input buffer or string will be written to a pipe (or TTY) in a single call [1][2]. Key Semantics and Behaviors: 1. Partial Writes: When writing to non-blocking file descriptors—such as pipes or TTYs, which Node.js and Bun often configure as non-blocking—fs.writeSync may perform a "short write" (a partial write) [1][3]. It returns the number of bytes actually written, rather than throwing an error or blocking until the full amount is sent [1][4][2]. The application is responsible for checking this return value and, if necessary, re-attempting the write with the remaining data [1]. 2. Blocking vs. Non-blocking: While fs.writeSync is technically a "synchronous" operation, it interfaces with underlying system calls (like write(2)) [4][5]. If the underlying file descriptor is in non-blocking mode (common for stdout/stderr pipes), the system call may return immediately if the pipe buffer is full, resulting in a partial write or an EAGAIN error [3]. Node.js historically does not retry these writes automatically within fs.writeSync [1]. 3. Bun Compatibility: Bun implements its own version of the Node.js fs API to maintain parity [2][6]. Its fs.writeSync behavior generally aligns with Node.js, returning the count of bytes written and adhering to standard syscall patterns [2]. Bun has also addressed specific issues regarding write offsets and buffer handling to ensure its implementation remains consistent with Node.js expectations [6][7]. 4. Practical Implications: Using fs.writeSync on pipes can lead to data truncation if the return value is ignored [1]. To avoid this, developers should wrap fs.writeSync in a loop that continues writing until the entire buffer is consumed or use asynchronous stream-based APIs (e.g., stream.write or pipe), which manage buffering and flow control automatically [1][8]. Note: Some newer experimental stream APIs in Node.js provide higher-level synchronous path abstractions (such as with FileHandle.writer) that may handle these complexities differently, but the standard fs.writeSync remains a low-level primitive with the semantics described above [9][10][11].
Citations:
fs.writeSynctruncates long lines nodejs/node#1541🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 2687
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 20932
🌐 Web query:
site:nodejs.org/api/fs.html fs.writeSync return value partial write non-blocking pipe💡 Result:
In Node.js, the fs.writeSync method behaves differently depending on the context in which it is used. For standard file system operations, fs.writeSync(fd, buffer, offset, length, position) returns the number of bytes written [1][2][3]. Because it is a synchronous operation, it typically blocks until the write completes or an error occurs. However, in the context of Node.js streams and the pipeTo pipeline, fs.writeSync has a distinct implementation designed for a try-sync fast path [4][5]. In this specific context, fs.writeSync returns a boolean: true if the write succeeded, and false if the caller should fall back to the asynchronous write method [4]. This return value is used to signal flow control, such as when the writer is closed, an async operation is already in flight, or the write would exceed specific limits [4]. Regarding non-blocking pipes, synchronous methods like fs.writeSync generally block the event loop until the operation completes. If you are working with pipes that may be non-blocking or have backpressure constraints, relying on the return value for flow control (as specified in the stream implementation) or using the asynchronous variants (write/writev) is the standard practice to avoid blocking the event loop [4][5].
Citations:
🌐 Web query:
Bun node:fs writeSync documentation nonblocking pipe partial write EAGAIN💡 Result:
In Bun, the node:fs
writeSyncfunction is a direct wrapper that maps to the underlying operating system's write operation for the provided file descriptor [1][2]. If the file descriptor is a non-blocking pipe (or socket),writeSyncdoes not automatically retry if the write operation cannot complete immediately [3]. Instead, it will return the number of bytes successfully written (which may be less than the length of the input buffer) or throw an error withcode: 'EAGAIN'if no data could be written [3]. This behavior differs from the intuitive expectation that a "synchronous" function should block until the entire payload is delivered [3]. Because Bun's standard streams (likeprocess.stdout) can sometimes be configured as non-blocking pipes, applications writing large amounts of data to these streams may encounter partial writes orEAGAINerrors when the OS pipe buffer fills up [4][3]. To safely write an entire buffer to a non-blocking file descriptor usingwriteSync, you must manually implement a retry loop [5][3]. A robust implementation should: 1. Track the current offset within the buffer [5]. 2. CallwriteSyncin a loop until the entire buffer is consumed [5]. 3. Handle partial writes by updating the offset based on the returned byte count [5]. 4. CatchEAGAIN(and potentiallyEINTR) errors, and implement a brief wait (e.g., using a short sleep orAtomics.wait) before retrying the operation with the remaining data [5][3]. Bun also provides aretryEAGAINcallback option withinfs.Utf8StreamOptionsfor asynchronous stream operations, which allows you to define custom logic for handlingEAGAINorEBUSYwhen using higher-level stream APIs, but this does not apply to the low-levelfs.writeSynccall [6][7].Citations:
🌐 Web query:
Bun stdout file descriptor blocking non-blocking pipe writeSync💡 Result:
In Bun, the behavior of
fs.writeSyncwhen writing to astdoutfile descriptor that is a pipe depends on whether the underlying pipe is configured in non-blocking mode [1][2]. 1. Blocking vs. Non-blocking: By default,stdoutin many environments may be treated as a non-blocking pipe [1][2]. If you usefs.writeSyncon a non-blocking pipe and the pipe buffer is full, the write operation will return a result indicating an error—typicallyEAGAINorEWOULDBLOCK—rather than blocking the thread to wait for space [1][2]. 2. HandlingEAGAIN: BecausewriteSyncdoes not automatically block to retry when a pipe is full, your application must handle these partial writes or retry errors manually [2]. Developers often implement a loop that detectsEAGAIN, waits for the pipe to become writable (e.g., usingAtomics.waitor similar mechanisms if appropriate), and then attempts to write the remaining bytes [2]. 3. Internal Implementation: Bun's internal C/Zig code distinguishes between different file types [1]. For pipes, it may use specific non-blocking write routines [1]. When the runtime itself manages writes (e.g., viaBun.writeorprocess.stdout.write), it handles backpressure and buffering automatically to ensure data is not silently truncated [1][3][4]. However, when you bypass these abstractions usingfs.writeSync, you assume responsibility for handling the state of the file descriptor [2]. In summary,writeSyncon a non-blockingstdoutpipe in Bun is not implicitly blocking; if the pipe is full, it will return anEAGAINerror, requiring your code to perform its own retry logic to ensure the full payload is written [1][2].Citations:
Write the complete login-start payload at
src/cli/account-auth.ts:38-39.When fd 1 is a non-blocking pipe,
writeSync(1, chunk)can short-write or throwEAGAIN. The ignored byte count can truncate the authorization URL or instructions. Encode the string once, loop over the remaining bytes, and handleEAGAIN/EINTRplus zero-byte progress.🤖 Prompt for AI Agents