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
51 changes: 48 additions & 3 deletions hypaware-core/plugins-workspace/claude-account/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { spawn } from 'node:child_process'
import http from 'node:http'
import readline from 'node:readline/promises'

import { askLineOnce } from '../../../../src/core/cli/line_asker.js'
import { CLAUDE_ACCOUNT_CONFIG_SECTION, resolveMode, validateClaudeAccountConfig } from './config.js'
import { resolveCredential } from './credential.js'
import {
Expand All @@ -22,6 +23,7 @@ import {
} from './store.js'

/**
* @import { Interface } from 'node:readline/promises'
* @import { PluginActivationContext, CommandRunContext } from '../../../../hypaware-plugin-kernel-types.js'
* @import { AnthropicCredentialCapability } from './types.js'
*/
Expand Down Expand Up @@ -131,6 +133,43 @@ async function runCredential(cmdCtx, config, stateDir) {
}
}

/**
* The `Code: ` paste fallback as its own lane, so the rule it encodes for
* a stdin that can no longer answer is readable and testable without a
* browser.
*
* `rl.question` leaves its promise permanently unsettled at EOF, so with
* no loopback listener to race - the port could not be bound, or the
* consumer flow fell back to the hosted callback - a login on a stdin
* that dried up waited forever on a paste that could never arrive.
* `askLineOnce` settles that as `null`, and the two cases part here:
*
* - No listener: nothing else can finish the login, so EOF is a real
* failure and says so, and `runLogin` prints it and exits 1.
* - A listener is up: the browser can still land on it, so the paste lane
* deliberately stays pending rather than losing the race for it. An EOF
* on the fallback input is not evidence about the primary flow, and a
* rejection here would settle `Promise.race` and abort a sign-in that
* was still on its way.
*
* `null` is not folded into the empty line the way the wizard's prompts
* fold it: `parsePastedAuthorization('')` throws `empty authorization
* code`, so an unanswerable prompt would report itself as a malformed
* paste the user never made.
*
* @ref LLP 0190#eof-everywhere [implements]: a spent stdin settles the prompt instead of waiting on an answer that can never come; this prompt has no default, so it settles as a failure rather than an answer
*
* @param {{ rl: Interface, stdin: NodeJS.ReadableStream, hasCallback: boolean }} args
* @returns {Promise<{ code: string, state: string }>}
*/
export function pasteAuthorizationLane({ rl, stdin, hasCallback }) {
return askLineOnce(rl, stdin, 'Code: ').then((pasted) => {
if (pasted !== null) return parsePastedAuthorization(pasted)
if (hasCallback) return new Promise(() => {})
throw new Error('stdin ended before an authorization code was pasted')
})
}

/**
* @param {CommandRunContext} cmdCtx
* @param {'org_key' | 'subscription'} mode
Expand Down Expand Up @@ -164,9 +203,15 @@ async function runLogin(cmdCtx, mode, stateDir) {
output: /** @type {NodeJS.WritableStream} */ (/** @type {unknown} */ (cmdCtx.stdout)),
})
try {
const pastePromise = rl.question('Code: ').then((pasted) => parsePastedAuthorization(pasted))
// A settled race leaves the loser pending; readline close (finally)
// rejects a pending question, so keep that rejection handled.
const pastePromise = pasteAuthorizationLane({
rl,
stdin: /** @type {NodeJS.ReadableStream} */ (cmdCtx.stdin),
hasCallback: callback !== null && callback !== undefined,
})
// A settled race leaves the loser pending. Closing the interface does
// not settle it either way (that is the defect `askLineOnce` works
// around), so what is guarded here is a malformed paste landing after
// the callback already won: keep that rejection handled.
pastePromise.catch(() => {})
const { code, state } = await (callback
? Promise.race([callback.result, pastePromise])
Expand Down
32 changes: 32 additions & 0 deletions llp/0190-wizard-defaults-gate.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,38 @@ everything behind the prompt - the store schema, editor semantics over
shown candidates, seam enforcement, corrupt-store fail-closed - is
LLP 0188's and is unchanged.

<a id="eof-everywhere"></a>**The EOF rule is the tree's, not this
lane's, and the asker that implements it is shared.** The defect is
`node:readline`'s, so it is at every readline prompt HypAware has, and
the rule above answers all of them: **a stdin that can no longer answer
takes the default the prompt printed; a prompt whose enter has no
default settles as a cancel or a failure instead, never as an invented
answer.** `queuedLineAsker` moves out of `walkthrough.js` into
`src/core/cli/line_asker.js`, beside `stdio.js` and `flush-streams.js`,
where a plugin workspace can import it too; `askLineOnce` joins it for
the prompts that ask once on an interface that may be a real terminal,
keeping `rl.question` as the thing that writes the query (readline
redraws a terminal line from its own bookkeeping, so a query it never
saw is a query it cannot redraw) and replacing only its promise.

Applied outward, the rule settles the exit code the class kept raising.
The wizard's fork menu prints `default 3`, and LLP 0129 #fork already
settled that the default is Quit, so EOF there is Quit and the wizard
exits 0 having written nothing: the same result the fork's TUI path
already returns for a real ctrl+c at that screen. 130 is not the answer
for a dropped terminal at a prompt that advertised a default, because a
prompt with a default cannot tell a dropped terminal from a bare enter
and should not pretend to; 130 stays where LLP 0135's cancel put it, at
the prompts whose enter answers nothing. The two `[y/N]` confirms
(`src/core/cli/confirm.js`, `src/core/plugin_install/confirm.js`) take
their printed no, which is the safe direction for the irreversible verbs
behind them, and their callers' exit codes are unchanged: an EOF decline
is reported exactly as a typed `n` is. The one prompt with no default is
`claude-account login`'s `Code: ` paste, and it does not invent one: with
no loopback listener left to finish the sign-in, EOF is a failure that
says so, and with a listener up the paste lane stays pending rather than
losing a race the browser may still win.

<a id="prompt-shape"></a>**One gate prompt shape for both lanes.** The
gate is a `ConfirmSelectQuestion` asked through
`defaultConfirmSelectPromptFactory`: a TUI select on a real TTY, a
Expand Down
17 changes: 14 additions & 3 deletions src/core/cli/confirm.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import process from 'node:process'
import readline from 'node:readline/promises'

import { askLineOnce } from './line_asker.js'
import { isTty } from './stdio.js'

/**
Expand All @@ -17,18 +18,28 @@ import { isTty } from './stdio.js'
* Anything other than `y`/`yes` is a no: the default has to be the safe
* one for a verb nobody can undo.
*
* That includes a terminal that stops being able to answer. `rl.question`
* leaves its promise permanently unsettled at EOF, so a ctrl+D or a
* dropped session hung the irreversible verb on its own confirmation
* instead of declining it. `askLineOnce` settles that case as `null`,
* read here as the empty line the `[y/N]` already treats as a no - so
* the EOF answer is the printed default, and cannot drift from it.
*
* @ref LLP 0190#eof-everywhere [implements]: a spent stdin lands on the prompt's stated default rather than waiting on an answer that can never come
*
* @param {CommandRunContext} ctx
* @param {string} question rendered verbatim, including its `[y/N]` suffix
* @returns {Promise<boolean>}
*/
export async function askYesNo(ctx, question) {
const input = /** @type {NodeJS.ReadableStream} */ (ctx.stdin ?? process.stdin)
const rl = readline.createInterface({
input: /** @type {NodeJS.ReadableStream} */ (ctx.stdin ?? process.stdin),
input,
output: /** @type {NodeJS.WritableStream} */ (/** @type {unknown} */ (ctx.stderr)),
})
try {
const answer = await rl.question(question)
return /^y(es)?$/i.test(answer.trim())
const answer = await askLineOnce(rl, input, question)
return /^y(es)?$/i.test((answer ?? '').trim())
} finally {
rl.close()
}
Expand Down
160 changes: 160 additions & 0 deletions src/core/cli/line_asker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// @ts-check

/**
* @import { Interface } from 'node:readline/promises'
*/

/**
* Ask one question and settle even when the stream can no longer answer.
*
* The same EOF defect `queuedLineAsker` documents below, for the prompts
* that ask exactly once and are not built with `terminal: false`. There,
* writing the query directly to the output stream is not equivalent to
* asking: on a real terminal readline redraws the line it is editing from
* its own cursor bookkeeping, and a query it never saw is a query it
* cannot redraw. So this keeps `rl.question` as the thing that writes and
* reads, and only replaces the promise, which is the part that is broken.
*
* Two settlements are added to it: `close`, for a stream that ends while
* the question is on screen (a terminal that dropped, a ctrl+D), and the
* stream's own `readableEnded`, for a stream that was already spent when
* the interface was built. Readline registers its `end` listener at
* construction, so the second case never emits `close` at all and a
* `close`-only guard still hangs on it. Both resolve `null`, which callers
* read as the empty line - the answer a bare enter would have given.
*
* Neither settlement may outrun an answer that did arrive, and both can:
* `close` fires synchronously, while `rl.question` hands its answer back
* through a promise, a microtask later. A stream that delivers the line
* and EOF in one burst (`Readable.from(['y\n'])`, any readable that
* pushes data and `null` together) would have `close` win that race and
* discard a real `y`, so the EOF settlements are deferred a turn and the
* delivered line settles first. Readline also routes the last line of a
* stream that ends without a trailing newline to `line` rather than to
* the pending question, which is a second answer `question` alone never
* sees, so that one is taken too - but only at EOF, and only if the
* question never got a line of its own. Readline emits `line` while no
* question is pending, so the same event also carries anything typed or
* pasted PAST the answer ("n\ny\n" in one burst), and settling on that
* directly would answer with the last line of the burst rather than the
* first: a typed `n` at an irreversible confirm would read as `y`.
*
* `rl.question`'s own promise is left permanently unsettled at EOF rather
* than rejected, so nothing here can leak an unhandled rejection; the
* handler attached to it is only there for the abort/close rejections
* newer readline versions may raise.
*
* @ref LLP 0190#eof-everywhere [implements]: a spent stdin settles on the prompt's stated default instead of waiting on an answer that can never come
*
* @param {Interface} rl
* @param {NodeJS.ReadableStream} input
* @param {string} prompt
* @returns {Promise<string | null>} the answer line, `null` once the stream is spent
*/
export function askLineOnce(rl, input, prompt) {
return new Promise((resolve) => {
let settled = false
/** @param {string | null} line */
const done = (line) => {
if (settled) return
settled = true
resolve(line)
}
/**
* The last line readline emitted outside the question, held rather
* than settled on. Only read if the question itself never answered.
* @type {string | null}
*/
let unaskedLine = null
// A turn behind the answer, never ahead of it: an answer delivered in
// the same burst as the EOF has to settle first.
const endOfInput = () => setImmediate(() => done(unaskedLine))
rl.once('close', endOfInput)
// Readline emits `line` only while no question is pending, so this is
// either the unterminated last line of the stream (an answer the
// question never sees) or a line that arrived past the answer. Holding
// it for EOF takes the first without letting the second overwrite an
// answer the question already has.
rl.on('line', (line) => {
unaskedLine = line
})
// Writes the query synchronously, on a spent stream too.
rl.question(prompt).then(done, () => done(null))
if (/** @type {{ readableEnded?: boolean }} */ (input).readableEnded === true) endOfInput()
})
}

/**
* Ceiling on the answer lines held for a still-unasked prompt. A prompt
* interface asks a small fixed number of questions and is closed straight
* after, so anything past a handful is unreadable backlog: a pipe that
* floods stdin (`yes |`) must not grow an array for as long as the prompt
* is on screen.
*/
const MAX_QUEUED_LINES = 4

/**
* Read answer lines off a readline interface without losing one and
* without ever waiting on a stream that can no longer answer.
*
* `rl.question()` cannot do either job here. It registers its `line`
* listener only when it is called, so a second answer line arriving in
* the same chunk as the first ("y\n3\n" from a pipe) is emitted and
* dropped before a re-ask can ask: readline emits both synchronously
* and the next `question()` is a microtask away. And at EOF its promise
* is left permanently unsettled - the interface closes, `question`
* neither resolves nor rejects - which is a hang, or a silent
* "unsettled top-level await" exit. Queueing every line from
* construction fixes the first; resolving the pending ask as `null` on
* `close` fixes the second.
*
* `close` alone is not enough for the second job. Readline registers its
* `end` listener when the interface is built, so an interface built over
* a stream that has ALREADY ended never sees an `end` and never emits
* `close` - the second prompt asked on a spent stdin would wait forever
* even though the first resolved. The stream's own `readableEnded` is
* the answer readline can no longer give, so it seeds `closed` here.
*
* This lives beside `stdio.js` and `flush-streams.js` rather than inside
* any one prompt: every readline prompt in the tree has the same EOF
* defect, and a shared asker is what keeps the answer to it in one place.
*
* @ref LLP 0190#eof-everywhere [implements]: a spent stdin settles on the prompt's stated default instead of waiting on an answer that can never come
*
* @param {Interface} rl
* @param {NodeJS.ReadableStream} input
* @param {NodeJS.WritableStream} output
* @returns {(prompt: string) => Promise<string | null>} writes one prompt and takes the next line, `null` once the stream is spent
*/
export function queuedLineAsker(rl, input, output) {
/** @type {string[]} */
const queued = []
/** @type {((line: string | null) => void) | null} */
let waiting = null
let closed = /** @type {{ readableEnded?: boolean }} */ (input).readableEnded === true
const take = () => {
const resolve = waiting
waiting = null
return resolve
}
rl.on('line', (line) => {
const resolve = take()
if (resolve) resolve(line)
else if (queued.length < MAX_QUEUED_LINES) queued.push(line)
})
rl.on('close', () => {
closed = true
const resolve = take()
if (resolve) resolve(null)
})
return function askLine(prompt) {
// Byte-identical to what `rl.question` writes: with `terminal: false`
// readline puts the query straight on the output stream.
output.write(prompt)
if (queued.length > 0) return Promise.resolve(/** @type {string} */ (queued.shift()))
if (closed) return Promise.resolve(null)
return new Promise((resolve) => {
waiting = resolve
})
}
}
Loading
Loading