Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/t12010-login-exit-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
id: t12010-login-exit-clean
tasks: [T12010]
kind: fix
summary: cleo login exits cleanly after OAuth success — pause stdin after paste-back read to release the event-loop hold
---

Fixes owner-reported hang: `cleo login anthropic` completed the browser OAuth flow (credential stored, selector working) but the process never exited and had to be killed hours later.

Root cause: `_headlessPkceFlow` in `packages/cleo/src/cli/commands/llm-login.ts` calls `process.stdin.resume()` to un-pause stdin so the user can paste the authorization code back. After `process.stdin.once('data', handler)` fires and the Promise resolves, `stdin` remains in flowing mode. A flowing `Readable` stream keeps the Node.js event loop alive indefinitely, preventing the process from exiting even after all application work is complete.

Fix: call `process.stdin.pause()` as the first statement inside the `once('data', ...)` handler — before `resolve` or `reject` — on every exit path (success, missing-code error, CSRF state-mismatch error). This stops the flow immediately after consuming one chunk and lets the event loop drain naturally.

Three regression tests assert that `process.stdin.pause` is invoked on the success path, the no-code-in-URL error path, and the CSRF-state-mismatch error path. The fix does not affect `--api-key-stdin` piped mode, `cleo auth login`, or interactive provider-picker prompts (which use `ReadlineWizardIO`, not raw stdin).
118 changes: 118 additions & 0 deletions packages/cleo/src/cli/commands/__tests__/llm-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,3 +861,121 @@ describe('runLlmLogin — T11958: Anthropic exchange wire shape', () => {
expect(m.exchangePkceCode).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// T12010 — regression: process.stdin must be paused after paste-back read
// (open stdin keeps the Node.js event loop alive → cleo login hangs forever)
// ---------------------------------------------------------------------------

describe('runLlmLogin — T12010: stdin paused after headless paste-back (no event-loop leak)', () => {
it('pauses stdin after receiving the code on the success path', async () => {
// Track whether pause() was called using a plain property override so we
// don't rely on vi.spyOn against a stream prototype method.
let pauseCalled = false;
const origPause = process.stdin.pause.bind(process.stdin);
process.stdin.pause = (() => {
pauseCalled = true;
return origPause();
}) as typeof process.stdin.pause;

const stdinSpy = vi
.spyOn(process.stdin, 'once')
.mockImplementation((event: string, listener: (...args: unknown[]) => void) => {
if (event === 'data') {
setTimeout(() => listener('http://localhost?code=t12010-code&state='), 0);
}
return process.stdin;
});
// Prevent resume() from actually flowing the real stdin (which is not a real TTY).
const resumeSpy = vi.spyOn(process.stdin, 'resume').mockReturnValue(process.stdin);

m.exchangePkceCode.mockResolvedValue({
accessToken: 'sk-ant-oat-t12010',
expiresIn: 3600,
tokenType: 'bearer',
});
m.addCredential.mockResolvedValue({ provider: 'anthropic', label: 'oauth-login' });

const result = await runLlmLogin('anthropic', { headless: true });

stdinSpy.mockRestore();
resumeSpy.mockRestore();
// Restore original pause (was replaced with tracking wrapper above).
process.stdin.pause = origPause as typeof process.stdin.pause;

// The flow must succeed.
expect(result.success).toBe(true);
// stdin.pause() MUST have been called inside the 'data' handler (T12010 fix).
expect(pauseCalled).toBe(true);
});

it('pauses stdin even when the pasted redirect URL has no code (error path does not leak)', async () => {
// An empty query string URL has no `code` parameter — parseAuthorizationInput
// returns `{ code: undefined }`, causing _headlessPkceFlow to reject.
let pauseCalled = false;
const origPause = process.stdin.pause.bind(process.stdin);
process.stdin.pause = (() => {
pauseCalled = true;
return origPause();
}) as typeof process.stdin.pause;

const stdinSpy = vi
.spyOn(process.stdin, 'once')
.mockImplementation((event: string, listener: (...args: unknown[]) => void) => {
if (event === 'data') {
// A URL with NO code= param → parseAuthorizationInput returns { code: undefined }.
setTimeout(
() => listener('https://platform.claude.com/oauth/code/callback?state=abc'),
0,
);
}
return process.stdin;
});
const resumeSpy = vi.spyOn(process.stdin, 'resume').mockReturnValue(process.stdin);

const result = await runLlmLogin('anthropic', { headless: true });

stdinSpy.mockRestore();
resumeSpy.mockRestore();
process.stdin.pause = origPause as typeof process.stdin.pause;

// The flow must fail (no code in URL), but stdin must still be paused.
expect(result.success).toBe(false);
expect(result.error?.code).toBe('E_PKCE_INVALID_CALLBACK');
expect(pauseCalled).toBe(true);
});

it('pauses stdin when the OAuth state does not match (CSRF error path)', async () => {
let pauseCalled = false;
const origPause = process.stdin.pause.bind(process.stdin);
process.stdin.pause = (() => {
pauseCalled = true;
return origPause();
}) as typeof process.stdin.pause;

const stdinSpy = vi
.spyOn(process.stdin, 'once')
.mockImplementation((event: string, listener: (...args: unknown[]) => void) => {
if (event === 'data') {
// Emit a redirect URL with a deliberately wrong state → CSRF rejection.
setTimeout(
() =>
listener('https://platform.claude.com/oauth/code/callback?code=x&state=WRONG-STATE'),
0,
);
}
return process.stdin;
});
const resumeSpy = vi.spyOn(process.stdin, 'resume').mockReturnValue(process.stdin);

const result = await runLlmLogin('anthropic', { headless: true });

stdinSpy.mockRestore();
resumeSpy.mockRestore();
process.stdin.pause = origPause as typeof process.stdin.pause;

expect(result.success).toBe(false);
expect(result.error?.code).toBe('E_PKCE_INVALID_CALLBACK');
expect(pauseCalled).toBe(true);
});
});
4 changes: 4 additions & 0 deletions packages/cleo/src/cli/commands/llm-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,10 @@ async function _headlessPkceFlow(
return new Promise<string>((resolve, reject) => {
process.stdin.setEncoding('utf8');
process.stdin.once('data', (chunk) => {
// Pause immediately after reading one chunk so the resumed stream does
// not keep the Node.js event loop alive after the promise settles
// (T12010 — process hung indefinitely after successful OAuth).
process.stdin.pause();
const parsed = parseAuthorizationInput(String(chunk));
if (!parsed.code) {
reject(new Error('Pasted input is missing the authorization "code" parameter'));
Expand Down
Loading