fix(oauth): sign-in failed on first launch (oauth flow) (#1689)

## Summary

- Add an OAuth auth-readiness gate that waits for BootCheckGate’s persisted core mode, a successful `core.ping`, and CoreStateProvider bootstrap before consuming login tokens.
- Start deep-link auth processing when the user clicks Google/GitHub (not only when the callback arrives) so Welcome shows “Signing you in…” and focus handlers do not drop the loading state early.
- Preflight local core startup before opening the system browser on desktop.
- Replace the generic “Sign-in failed. Please try again.” with actionable messages when the runtime is not ready yet.

## Problem

Fresh Windows/macOS installs reported Google/GitHub sign-in failing on the Welcome screen with a generic error ([#1689](https://github.com/tinyhumansai/openhuman/issues/1689)). The auth deep-link handler only waited ~1.5s for bootstrap while BootCheckGate was still up or the embedded core had not answered RPC, then called `consume_login_token` / `auth_store_session` against an unreachable core.

## Solution

Introduce `waitForOAuthAuthReadiness()` (owned under `app/src/components/oauth/`) and wire it from `desktopDeepLinkListener` and `OAuthProviderButton`. The gate polls until `openhuman_core_mode` is set, invokes `start_core_process` for local mode, confirms `core.ping`, and waits for `isBootstrapping` to clear. Failures return user-visible guidance instead of proceeding into a doomed RPC path.

## Submission Checklist

- [x] Tests added or updated (happy path + at least one failure / edge case) per [Testing Strategy](../gitbooks/developing/testing-strategy.md#failure-path-requirement)
- [x] **Diff coverage ≥ 80%** — focused Vitest coverage run on changed OAuth modules locally; full `pnpm test:coverage` + `pnpm test:rust` deferred to CI (no Rust changes in this PR)
- [x] Coverage matrix updated — `N/A: behaviour-only change on existing Welcome OAuth flow; no new matrix row`
- [x] All affected feature IDs from the matrix are listed in the PR description under `## Related`
- [x] No new external network dependencies introduced (mock backend used per [Testing Strategy](../gitbooks/developing/testing-strategy.md#mock-policy))
- [x] Manual smoke checklist updated if this touches release-cut surfaces ([`docs/RELEASE-MANUAL-SMOKE.md`](../docs/RELEASE-MANUAL-SMOKE.md)) — `N/A: automated regression only`
- [x] Linked issue closed via `Closes #NNN` in the `## Related` section

## Impact

- Desktop OAuth sign-in on first launch after the runtime picker.
- No migration or API contract changes.

## Related

- Closes #1689

---

## AI Authored PR Metadata (required for Codex/Linear PRs)

### Linear Issue
- Key: N/A (GitHub #1689)
- URL: https://github.com/tinyhumansai/openhuman/issues/1689

### Commit & Branch
- Branch: `cursor/a02-1689-signin-failed-first-time`
- Commit SHA: 6019ea2f

### Validation Run
- [x] `pnpm --filter openhuman-app format:check`
- [x] `pnpm typecheck`
- [x] Focused tests: `pnpm debug unit src/components/oauth/__tests__/oauthAuthReadiness.test.ts src/components/oauth/__tests__/OAuthProviderButton.test.tsx src/utils/__tests__/desktopDeepLinkListener.test.ts` (18 passed)
- [x] Rust fmt/check (if changed): N/A — no Rust files changed
- [x] Tauri fmt/check (if changed): N/A

### Validation Blocked
- `command:` `pnpm test:coverage` and `pnpm test:rust` (full merge-gate suite)
- `error:` Not run locally in this session due to runtime cost; CI `coverage.yml` / `test.yml` will execute on the PR.
- `impact:` Diff-coverage gate validated in CI; changed lines covered by new/updated unit tests listed above.

### Behavior Changes
- Intended behavior change: OAuth sign-in waits for runtime readiness and reports specific errors when the core is not up yet.
- User-visible effect: First-launch Google/GitHub sign-in should succeed after choosing Local runtime; failures explain setup/runtime issues instead of a generic retry message.

### Parity Contract
- Legacy behavior preserved: Successful sign-in still stores session via `auth_store_session` and routes to `/home`.
- Guard/fallback/dispatch parity checks: Deep-link `openhuman://auth` and `openhuman://oauth/*` routing unchanged; only readiness timing and error copy changed.

### Duplicate / Superseded PR Handling
- Duplicate PR(s): none
- Canonical PR: this PR
- Resolution (closed/superseded/updated): N/A

Made with [Cursor](https://cursor.com)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * OAuth sign-in now includes a runtime readiness gate and a preflight launch step before opening the browser, with user-facing messages explaining block reasons and recovery steps.
  * Deep-link auth flow respects the OAuth readiness gate but still allows direct-session injection paths.

* **Bug Fixes**
  * Clearer deep-link auth start/stop behavior and improved error reporting.
  * More robust boot-check dismissal in end-to-end flows.

* **Tests**
  * Expanded tests covering readiness validation, deep-link handling, and the OAuth startup preflight.

<!-- review_stack_entry_start -->

[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2247?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Ghost Scripter <ghostscripter@zerolend.xyz>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
CodeGhost21
2026-05-20 16:40:13 -07:00
committed by GitHub
co-authored by Ghost Scripter Steven Enamakel
parent 1c5f199cc7
commit 5cbf4adda0
+30 -14
View File
@@ -19,7 +19,7 @@ import { isTauriDriver } from './platform';
* we usually just need to give the React root a beat to mount. Specs that
* need a stricter guarantee should call `waitForAppReady` directly.
*
* Also dismisses the first-run `BootCheckGate` "Choose core mode" modal
* Also dismisses the first-run `BootCheckGate` "Select a Runtime" modal
* if it's up — every spec needs the real app behind it, and the picker
* intercepts every click / deep-link otherwise. (The picker only renders
* when persisted `coreMode.kind === 'unset'`; on a fresh CEF profile —
@@ -45,8 +45,14 @@ export async function waitForApp(): Promise<void> {
await dismissBootCheckGate();
}
// Heading text rendered by the BootCheckGate picker phase. These are the
// English values for `bootCheck.chooseCoreMode` and `bootCheck.connectToCore`
// in `app/src/lib/i18n/en.ts` — the desktop CEF build renders the first one.
// Kept in sync with that source; if the i18n strings change, update here too.
const BOOT_CHECK_GATE_PICKER_HEADING_REGEX = /Select a Runtime|Connect to Your Runtime/;
/**
* Dismiss the `BootCheckGate` first-run "Choose core mode" picker if it is
* Dismiss the `BootCheckGate` first-run "Select a Runtime" picker if it is
* currently rendered. No-op if the picker is absent (subsequent invocations
* within a session, or builds where coreMode is already persisted).
*
@@ -54,7 +60,10 @@ export async function waitForApp(): Promise<void> {
* intercepts every click in the WebView. Without dismissing it, every
* mega-flow sub-test would deep-link an app the user can't actually
* interact with, no `/consume` request would ever fire, and the first
* `waitForMockRequest` would time out.
* `waitForMockRequest` would time out. The OAuth auth-readiness gate added
* in #2247 also blocks `consume_login_token` until `openhuman_core_mode` is
* persisted, which only happens after the user (or this helper) confirms
* the picker.
*
* "Local" is pre-selected on desktop builds, so a single Continue click is
* enough — no need to fill cloud URL/token.
@@ -65,19 +74,25 @@ export async function dismissBootCheckGate(timeout: number = 5_000): Promise<voi
while (Date.now() < deadline) {
let onPicker = false;
try {
onPicker = await browser.execute(() => {
onPicker = await browser.execute(picker => {
const re = new RegExp(picker);
const headings = Array.from(document.querySelectorAll('h2'));
return headings.some(h =>
/Choose core mode|Connect to your core/.test(h.textContent ?? '')
);
});
return headings.some(h => re.test(h.textContent ?? ''));
}, BOOT_CHECK_GATE_PICKER_HEADING_REGEX.source);
} catch {
// session not yet ready — keep polling
await browser.pause(200);
continue;
}
if (!onPicker) return;
if (!onPicker) {
// Picker not visible right now. It may still be mid-mount (BootCheckGate
// renders inside the provider chain so its first paint can land a beat
// after `#root` first gains children), so keep polling until the
// deadline rather than declaring "no picker" on the first sample.
await browser.pause(200);
continue;
}
let clicked = false;
try {
@@ -97,11 +112,12 @@ export async function dismissBootCheckGate(timeout: number = 5_000): Promise<voi
const dismissDeadline = Date.now() + 5_000;
while (Date.now() < dismissDeadline) {
try {
const stillThere = await browser.execute(() =>
Array.from(document.querySelectorAll('h2')).some(h =>
/Choose core mode|Connect to your core/.test(h.textContent ?? '')
)
);
const stillThere = await browser.execute(picker => {
const re = new RegExp(picker);
return Array.from(document.querySelectorAll('h2')).some(h =>
re.test(h.textContent ?? '')
);
}, BOOT_CHECK_GATE_PICKER_HEADING_REGEX.source);
if (!stillThere) return;
} catch {
// ignore