fix(cli): exit cleanly on non-interactive SSO auth failure (EPMCDME-14148) - #532
Open
SleepySML wants to merge 16 commits into
Open
fix(cli): exit cleanly on non-interactive SSO auth failure (EPMCDME-14148)#532SleepySML wants to merge 16 commits into
SleepySML wants to merge 16 commits into
Conversation
Running any `codemie sdk` command with no valid SSO session and a non-TTY stdin exited 1 by letting a ConfigurationError escape to Node's default handler, printing a raw stack trace, and the message it printed had lost the remediation the user needed. The non-TTY hang named in the ticket title was already fixed by codemie-ai#471; what remained was the quality of the failure. Two independent defects, both required: - getSdkClient() acquired auth outside every action's try/catch, so the throw bypassed handleSdkError. All ~50 sdk actions across 8 files share this one gate, so it is fixed once here rather than at each call site, following the shared-gate approach codemie-ai#471 established. - promptReauthentication's generic 'Authentication expired' throw shadowed the upstream error that names `codemie setup`. The original error is now preserved. promptReauthentication itself is untouched, so its Promise<boolean> contract and the assistants/chat call site are unaffected. Also: auth diagnostics move to stderr so piped stdout and --json consumers stay clean, and the ora spinner is suppressed when non-interactive, where it emitted raw cursor-control escapes into captured output. Tests: new cli-utils suite (sdk/** had none), new sdk-client spinner suite, extended auth-validation coverage. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
… auth Commander actions are async but program.parse() is synchronous, so a rejection escaping an action reached neither the action's own try/catch nor the import().catch() in bin/codemie.js, and Node printed a raw stack. installProcessGuards() is the last-line-of-defence net; commands are still expected to handle their own errors. The guard lives in src/utils/ rather than inline in bin/ because bin/ is excluded from coverage and cannot be unit-tested. AUTHENTICATION.md now shows the message the CLI actually emits, rather than an approximation, and documents that diagnostics go to stderr and that spinners are suppressed without a TTY. Adds an end-to-end regression test asserting the acceptance criterion directly: non-zero exit, remediation text present, no stack trace. Verified honest by reverting the fix, where the remediation assertion fails while the others still pass. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
CR-001: process-guards destroyed the diagnostic it promised to relocate.
logger.error's second parameter is only unpacked when it is instanceof
Error, so passing { stack } stringified to "[object Object]" and the
stack was lost; the process.exit(1) that follows could also drop the
entry entirely on a cold write stream. The payload is now passed
through, the fatal detail is additionally appended synchronously, and
exitCode is set before anything that might exit early. Adds a companion
test that does NOT mock the logger and asserts the stack reaches the
file - verified to fail against the previous implementation.
CR-003: the guard was wired into 1 of 14 bin/ entrypoints, missing the
agent binaries whose AgentCLI path carries the SSO auth failure. It is
now installed from the AgentCLI constructor, and made idempotent since
both entrypoints can share a process.
CR-004: AUTHENTICATION.md asserted stdin-TTY detection was sufficient
for CI. It is not - a pty-allocating runner still reaches the prompt,
which is the originally reported failure. The boundary is now stated
explicitly rather than implied away.
CR-005: the AC1 regression test could not fail on a hang, because
runSilent wraps execSync and Vitest's testTimeout cannot interrupt a
synchronous call. Adds an execSync timeout, asserts the remediation
verbatim instead of a loose match, and adds the missing assertion that
the diagnostic stays off stdout.
CR-002 is a documentation correction, committed separately with the
planning artifacts.
EPMCDME-14148
Co-Authored-By: Claude <noreply@anthropic.com>
CR-002: ac4-investigation.md claimed the spinner suppression removed a plausible contributor to AC4. That is false - the suppression is gated on the environment being non-interactive, while AC4's scenario requires a TTY, so it can never fire there. The claim was the only justification offered for shipping without AC4; it is struck and marked as retracted rather than quietly deleted. AC4 is reclassified from "cannot reproduce" to "precondition not constructed, criterion never exercised" - 0 of 8 attempts reached the prompt, so the criterion was never actually tested. Carries the concrete reproduction recipe for the follow-up ticket. Also corrects reproduction.md, which twice called the --non-interactive flag undocumented when its absence is documented in AUTHENTICATION.md. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
…o fail Follow-up to the review check round, which found two defects introduced by the previous fix-up. Installing the guards from the AgentCLI constructor meant merely constructing an AgentCLI mutated global process state: listener counts for uncaughtException and unhandledRejection went 1 -> 2, and four unit test files construct AgentCLI directly. Any later unhandled rejection in that worker would hard-exit instead of surfacing as a test failure. Moving the call to AgentCLI.run() would not have helped either, since tests drive run() as the seam. The guard is now installed in each of the 11 agent entrypoints alongside the existing bin/codemie.js call, so construction stays side-effect free. Daemons are deliberately left out: exiting on the first unhandled rejection is wrong for a long-running process, and codemie-mcp-proxy already owns handlers with a survive-on-rejection policy. The 15s execSync timeout stopped CI wedging but did not make a hang detectable. On ETIMEDOUT execSync reports status: null, which runSilent collapsed to exitCode 1, and stderr already held the remediation printed before the block — so all four cases went green during a genuine hang, including the one named for it. Verified directly against a child that prints then hangs. CommandResult now carries timedOut and the test asserts it, so the regression these tests exist to catch is detectable. Also corrects two internal contradictions in reproduction.md: Case D claimed signals were sent "while parked on the prompt", which the later pty work disproved, and the AC table still cited retracted Case C as evidence. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
Adds code-review-check.json: CR-001..CR-005 resolved, plus CR-006 and CR-007 which the check round found were introduced by the first fix-up. Closes three residuals the checker raised: the logfile test now asserts its log path instead of silently early-returning (it could otherwise pass vacuously where log init fails), and AUTHENTICATION.md no longer implies CI always means no TTY. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
Independent verification of the CR-006 and CR-007 fixes confirmed both hold, and raised three minor points now addressed. The timedOut disjunct on signal === 'SIGTERM' was redundant: a genuine timeout always sets code ETIMEDOUT, including when the child ignores SIGTERM. The disjunct only misreported an unrelated SIGTERM death as a hang, demonstrated firing at 29ms. Reduced to the ETIMEDOUT check. The timeout only catches a hang that runs the full 15s, so a shorter stall would still pass every assertion and merely slow the suite. Adds a wall-clock bound with roughly 18x headroom over the observed runtime. Corrects the process-guards comment, which still claimed AgentCLI installs the guard - it has not since db0589a. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
Gates: license, lint, typecheck, build, unit (3969/3969), commitlint all pass. UI gate skipped - no UI surface in the diff. Two gates are owed to CI rather than passing locally, and the report says so rather than rounding up. The secrets scan self-skipped with 'No staged changes to scan' because it reads the staged diff and the tree is clean; it is recorded SKIPPED, not PASS, though the pre-commit hook did run gitleaks against every commit on the branch. The full integration suite cannot complete in this environment - doctor.test.ts and list.test.ts both hang - which was verified pre-existing by running doctor against origin/main's entrypoint inside the repo, where it hangs identically. The four integration files that do complete, including the new AC2 regression suite and the three that exercise bin/codemie.js, all pass. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
Actual L (23/36) against an initial estimate of M (19/36) - one band low, delta +4. The gap is concentrated in Technical Risk, driven by two hazards that were invisible at planning time and only surfaced during implementation: logger writes through an fs.WriteStream that process.exit() does not drain, so the fatal record was silently lost on a cold stream; and runSilent wraps execSync, which blocks the Vitest worker synchronously so testTimeout cannot interrupt it, meaning a regression to the original hang would have wedged CI rather than failed a test. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
origin/main advanced by one commit while this work was in progress (revert of the LiteLLM/SSO setup enforcement gate). Rebased and re-ran every gate against the rebased tree - all still pass. That commit also removed the test:unit and test:integration package scripts, so the report now cites the current commands. The unit count moves 3969 -> 3939 because the revert removed its own tests, not because anything here regressed. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
SleepySML
force-pushed
the
EPMCDME-14148
branch
from
September 4, 2026 08:03
508268f to
bb306fc
Compare
Code review raised this as a minor finding and it was deferred; this closes it. ora writes to stderr, but spinner suppression was gated on isNonInteractiveEnvironment(), which reads process.stdin.isTTY. Input and output redirect independently, so that was wrong in both directions: `codemie ... > run.log 2>&1` from a terminal kept the spinner and filled the log with cursor-control escapes - the exact symptom the change was meant to remove - while `codemie ... < input.json` needlessly dropped the spinner for a user watching the terminal. Adds isNonInteractiveOutput() (!process.stderr.isTTY) alongside the existing predicate rather than changing it: isNonInteractiveEnvironment() is about whether we can prompt, where stdin is the correct signal, and it still guards the re-auth prompt. Verified both directions empirically: with stderr redirected the run emits zero cursor-control sequences, and under a real pty the spinner still renders. The two unit cases that pin the distinction fail against the previous implementation. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
The recipe carried from code review assumed the only thing between a run and the re-auth prompt was the absence of credentials, so planting stale ones would reach it. Tracing shows that assumption is wrong. Measured under a real pty with a copy of the working config: stdin and stderr are both TTYs, ai-run-sso is registered, and validateAuth and promptForReauth are both present - every precondition the recipe assumed - yet promptForReauth is still never entered. It prints a warning banner before opening its readline interface and that banner never appears, so the function was not reached. Execution goes straight to the actionable message. The blocker is therefore not missing credentials, and the prompt may not be reachable from `codemie sdk ...` at all on this configuration - which casts doubt on the ticket's premise, not just on the crash. The recipe is marked superseded rather than deleted, with the measurements that undermine it. Redirects the follow-up ticket to answer "where is the prompt reachable" first, and points at AgentCLI.handleRun, which calls handleAuthValidationFailure directly rather than through getAuthenticatedClient - a branch this investigation never exercised. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
AC4 is now actually exercised rather than deferred. 12 of 12 runs
reached the interactive re-auth prompt under a real pty and were then
interrupted (Ctrl-C, SIGINT, SIGTERM, SIGHUP x three delays); none
produced ERR_USE_AFTER_CLOSE or any readline error.
Root cause of every earlier failure to reach the prompt was the test
environment, not the product. These probes ran from a shell CodeMie had
launched, which exports CODEMIE_PROVIDER=anthropic-subscription and
CODEMIE_PROFILE_CONFIG. ConfigLoader gives process.env precedence over
both config files, so every run resolved to anthropic-subscription - a
provider with no validateAuth and no promptForReauth - and terminated
before any prompt could appear. CODEMIE_HOME isolates config files but
not the env vars that outrank them. Stripping CODEMIE_* from the child
environment fixes it, and the prompt is then reached reliably.
This also retires the superseded reproduction recipe: planting stale
credentials was never necessary, because absent credentials already
return {valid:false} through the 'No SSO credentials found' branch,
which is enough to reach the prompt.
Four claims are now on record as retracted, all environment or harness
artifacts misread as product behaviour, including the previous
"prompt unreachable from codemie sdk" - which questioned the ticket's
premise on the strength of a polluted environment.
Records one incidental finding for its own ticket: Ctrl-C at the prompt
exits with code 0 rather than the conventional non-zero.
EPMCDME-14148
Co-Authored-By: Claude <noreply@anthropic.com>
Re-verifies every AC end-to-end through bin/codemie.js under the ticket's stated preconditions, with the environment actually clean. The earlier AC1/AC2 checks ran from a CodeMie-launched shell that exports CODEMIE_PROVIDER=anthropic-subscription, which outranks both config files, so they were verified under the wrong provider. They pass under the correct one too - the message originates in sdk-client.ts, which is provider-agnostic - but a criterion verified under the wrong provider is not verified. Two harness bugs in the verification itself were fixed first: zsh does not word-split an unquoted $VAR, so `env $FLAGS` passed 38 -u flags as one argument and stripped nothing (cleaning now happens inside Node); and the prompt matcher /authentication required/i also matched the legitimate error text 'SSO authentication required', reporting a prompt that never appeared. It now matches the exact inquirer question. AC1 now has a real control: with a TTY the prompt appears at 604ms, without one it does not. Without that control AC1 passes trivially whenever anything terminates the run early - which is how the earlier investigation fooled itself. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
120 comment lines across ~300 added lines of code, much of it restating what the code already says. The worst was bin/: the same three-line block duplicated across all 12 entrypoints, 35 lines total, explaining a decision that belongs in one place. The rationale for installing per entrypoint rather than in the AgentCLI constructor now lives once, on installProcessGuards itself; the call sites are self-describing. Elsewhere: trimmed narrative from process-guards, interactive, sdk-client, auth, auth-validation, cli-utils, cli-runner and the test files, keeping the why and the ticket reference and dropping restatement. Down to 50 lines. No behaviour change - build, typecheck, lint, 3946 unit, 4 integration all pass, and all four acceptance criteria re-verified end to end. EPMCDME-14148 Co-Authored-By: Claude <noreply@anthropic.com>
Four parallel review passes over the EPMCDME-14148 diff.
Correctness, and the most valuable find: the integration test built its
child env as {...process.env, CODEMIE_HOME}, which does not strip
inherited CODEMIE_* vars. ConfigLoader.loadFromEnv reads CODEMIE_PROVIDER
and friends straight from the environment, so an empty home does not by
itself mean "no valid SSO session" - run from a CodeMie agent shell the
test either fails spuriously or passes vacuously. tests/helpers/sso-auth
already exports ssoCleanEnv() for exactly this and six neighbouring
integration tests use it. This is the same env pollution that derailed
the AC4 investigation, reproduced in the test written to prevent it.
Efficiency: the integration test spawned the identical command four
times. One spawn in beforeAll feeding four it() blocks cuts the file from
~1.05s to ~280ms with no loss of per-assertion granularity.
Removed test cases that discriminate nothing: two of four TTY
combinations in sdk-client and two of four in interactive passed under
both the correct implementation and the stdin-gated one they exist to
rule out. Verified the survivors still fail against that regression.
Merged two cli-utils tests that duplicated a 13-line arrangement to
assert two halves of one behaviour.
Flattened the nested try in auth.ts to a .catch on the prompt alone -
wrapping the retry too would swallow its own failure.
reportFatal now appends "(see <logPath>)", reusing the pattern from
logger.notice: it strips the stack from the console and previously left
no pointer to where it went.
Adds a coverage test asserting every bin in package.json either installs
the guards or is an explicitly justified exclusion, so a new entrypoint
fails CI instead of silently shipping without a net. Verified it fails
when a guard is removed. Documents why mcp-proxy and proxy-daemon opt out.
Measured and left alone: the new import adds 0.29ms to startup, no new
packages, keytar still lazy. The logger.error/appendFileSync double write
is justified - the logger's file write never survives process.exit, so
appendFileSync is the only durable path and logger.error survives only as
the CODEMIE_DEBUG console echo.
EPMCDME-14148
Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
EPMCDME-14148 reports that a non-interactive SSO failure hangs on a re-auth prompt. Reproduction showed the hang is already fixed on
mainby5b2de4b7(#471). What is still broken is the quality of the failure: the CLI exits 1 by letting aConfigurationErrorescape to Node's default handler — printing a raw stack trace — and the actionable remediation is discarded one frame before it reaches the user.Two independent defects, both required — fixing either alone is insufficient, since
handleSdkErroralready rendersConfigurationErrorcleanly:getSdkClient()acquired auth outside every action'stry, so the throw bypassedhandleSdkError. All ~50 sdk actions across 8 files share that one gate, so it is fixed once there rather than at 50 call sites — the same shared-gate approach fix(providers): skip interactive re-auth prompt in non-interactive environments #471 used.promptReauthentication's generic'Authentication expired'throw shadowed the upstream error namingcodemie setup. The original is now preserved.Changes
sdk/utils/cli-utils.ts— route auth acquisition through the existinghandleSdkErrorsink.utils/auth.ts— preserve the actionable upstream error.promptReauthenticationitself is untouched, so itsPromise<boolean>contract and theassistants/chatconsumer cannot regress — and its existing tests still pass unmodified.providers/core/auth-validation.ts— diagnostics to stderr, keeping piped stdout and--jsonoutput clean.utils/interactive.ts+utils/sdk-client.ts— suppress theoraspinner based on the output stream.orawrites to stderr, so a newisNonInteractiveOutput()(!process.stderr.isTTY) gates it; the existing stdin-based predicate is left alone because prompting genuinely depends on stdin. Gating the spinner on stdin was wrong in both directions —codemie … > run.log 2>&1kept the spinner and filled the log with escapes, whilecodemie … < input.jsondropped it for a user watching the terminal.utils/process-guards.ts(new) + all 12bin/entrypoints — last-line-of-defence net for async rejections escaping a command action (program.parse()is sync, so these reach neither the action'strynor theimport().catch()). Installed per entrypoint, not from theAgentCLIconstructor, so constructing anAgentCLInever mutates global process state.docs/AUTHENTICATION.md— shows the message the CLI actually emits, and documents a real boundary: prompt detection readsstdinonly, so a pty-allocating runner (docker run -t, some Jenkins/GitLab configs) can still reach the prompt.sdk/**had zero tests across 22 files; adds the first. Plus newsdk-clientandprocess-guardssuites, and an end-to-end regression test.Impact
User-visible behaviour changes worth reviewer attention:
> log 2>&1, kept when stdin is redirected but the terminal is still attached.Acceptance criteria — 4 / 4 verified
Re-run end-to-end through
bin/codemie.jsunder the ticket's stated preconditions. Full record inacceptance-verification.md.1; namescodemie setup; no stack trace, noConfigurationError:, no Node banner; stdout clean--non-interactive/--cisupported or documentedERR_USE_AFTER_CLOSETwo notes on how that verification was reached, since both bear on how much to trust it:
The environment had to be cleaned first. These probes run from a shell CodeMie itself launched, which exports
CODEMIE_PROVIDER=anthropic-subscriptionandCODEMIE_PROFILE_CONFIG.ConfigLoadergivesprocess.envprecedence over both config files, so early runs resolved to the wrong provider — one with novalidateAuthand nopromptForReauth, which terminated before any prompt could appear.CODEMIE_HOMEisolates config files, not the env vars that outrank them. That single fact produced four claims that had to be retracted, all recorded inac4-investigation.md, including an earlier draft of this PR asserting the prompt was unreachable fromcodemie sdk ….AC1's control is the load-bearing part. Without demonstrating that a TTY does produce the prompt, AC1 passes trivially whenever anything else ends the run early — which is exactly how the earlier investigation fooled itself.
The ticket hedges AC4 with "can crash", so that one is a negative result on this build and platform (macOS, Node v24.19.0), not proof the failure is impossible everywhere.
One incidental finding, out of scope, worth its own ticket: Ctrl-C at the prompt exits with code 0. A script checking
$?after a user interrupts would conclude the command succeeded.Verification
Rebased onto
d7097a2aand all gates re-run against the rebased tree:license·lint --max-warnings=0·typecheck·build· unit 3946/3946 (267 files) ·commitlint— all pass. All 12 entrypoints start.Spinner behaviour verified empirically in both directions: with stderr redirected the run emits zero cursor-control sequences; under a real pty the spinner still renders.
Two gates are owed to CI, stated plainly rather than rounded up:
doctor.test.tsandlist.test.tshang in this environment. Verified pre-existing —doctorhangs identically againstorigin/main's entrypoint. The four files that do complete — including the new regression suite and the three exercisingbin/codemie.js— all pass.No staged changes to scan(it reads the staged diff; the tree is clean). The pre-commit hook did run gitleaks on every commit. Note it is hook-only —npm run cidoes not include it.Reviewed in two rounds. The first raised 5 blocking findings; the check round found 2 further defects introduced by that first fix-up (guards leaking handlers into test workers; a timeout that stopped CI wedging but left a hang passing every assertion). Both were fixed and independently re-verified — including a counterfactual proving the hang test now fails where it previously passed silently.
Checklist
🤖 Generated with Claude Code