fix(oauth): stabilize first-launch OAuth CI readiness

## Summary

- Restores the first-launch desktop OAuth flow from #2247 while unblocking the failing CI surfaces.
- Keeps the runtime readiness gate before launching OAuth in Tauri so first-launch sign-in waits for core/auth readiness.
- Stabilizes legacy OAuth provider tests by mocking the new readiness preflight in Tauri-mode unit tests.
- Makes the E2E-only `window.__simulateDeepLink` helper fire-and-forget, matching production `onOpenUrl` behavior so WebDriver does not block on auth readiness.
- Keeps auth readiness focused on core-mode selection plus core RPC reachability, so first-login callbacks are not blocked by CoreStateProvider bootstrap.
- Adds regression coverage for the non-blocking deep-link helper path.

## Problem

- #2247 fixes first-launch OAuth readiness, but its PR branch is not writable from this maintainer account, and CI is currently red.
- Frontend unit and coverage jobs fail because legacy provider tests exercise Tauri mode without mocking the new readiness preflight.
- Linux E2E times out because `browser.execute(async () => await window.__simulateDeepLink(...))` waits on the full auth callback path, including readiness waits.
- The PR checklist also had the diff coverage item unchecked.

## Solution

- Mock `prepareOAuthLoginLaunch()` in the legacy Google/GitHub/Discord/Twitter OAuth tests, preserving their existing URL/openUrl assertions while isolating the readiness preflight.
- Change `__simulateDeepLink` to schedule `handleDeepLinkUrls()` without awaiting it, which matches the real desktop deep-link listener's fire-and-forget handler.
- Treat CoreStateProvider bootstrap as observational logging rather than a hard auth-readiness gate; core mode plus a successful core RPC ping are the required login preconditions.
- Dismiss the runtime picker before E2E auth deep-link simulation so raw mega-flow auth callbacks also commit a core mode before readiness checks.
- Treat the E2E default local core mode as an auth-readiness core mode when no explicit localStorage marker exists.
- Add a desktop deep-link listener unit test proving the E2E helper resolves immediately while the auth readiness path continues asynchronously.
- This PR supersedes #2247 because the original contributor fork rejected direct pushes from `YOMXXX` despite `maintainerCanModify=true`.

## Submission Checklist

> If a section does not apply to this change, mark the item as `N/A` with a one-line reason. Do not delete items.

- [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%** — changed lines (Vitest + cargo-llvm-cov merged via `diff-cover`) meet the gate enforced by [`.github/workflows/coverage.yml`](../.github/workflows/coverage.yml). Run `pnpm test:coverage` and `pnpm test:rust` locally; PRs below 80% on changed lines will not merge.
- [x] Coverage matrix updated — N/A: behavior/test stabilization for existing OAuth sign-in flow; no feature row added/removed/renamed.
- [x] All affected feature IDs from the matrix are listed in the PR description under `## Related` — N/A: no matrix feature IDs changed.
- [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: no release smoke procedure changed.
- [x] Linked issue closed via `Closes #NNN` in the `## Related` section

## Impact

- Desktop OAuth first-launch sign-in is more reliable because OAuth launch still waits for readiness before opening the external browser.
- E2E-only deep-link simulation no longer blocks WebDriver script execution on long auth readiness waits.
- No new runtime dependency, migration, storage, or security surface.
- Web OAuth behavior is unchanged.

## Related

- Closes: Closes #1689
- Follow-up PR(s)/TODOs: Supersedes #2247 because the original head branch is not writable from this fork.

---

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

> Keep this section for AI-authored PRs. For human-only PRs, mark each field `N/A`.

### Linear Issue
- Key: N/A
- URL: N/A

### Commit & Branch
- Branch: `fix/2247-oauth-ci-readiness`
- Commit SHA: `3367058b145a37566dcd377198b2881a977ce3cd`

### Validation Run
- [x] `pnpm --filter openhuman-app format:check` (via pre-push hook)
- [x] `pnpm typecheck`
- [x] Focused tests:
  - `pnpm debug unit test/OAuthTwitter.test.tsx`
  - `pnpm debug unit src/utils/__tests__/desktopDeepLinkListener.test.ts`
  - `pnpm debug unit test/OAuthGitHub.test.tsx test/OAuthDiscord.test.tsx test/OAuthLoginSection.test.tsx`
  - `pnpm debug unit src/components/oauth/__tests__/OAuthProviderButton.test.tsx src/components/oauth/__tests__/oauthAuthReadiness.test.ts`
  - `pnpm debug unit src/components/oauth/__tests__/oauthAuthReadiness.test.ts src/utils/__tests__/desktopDeepLinkListener.test.ts src/components/oauth/__tests__/OAuthProviderButton.test.tsx test/OAuthTwitter.test.tsx`
  - `pnpm debug unit src/utils/__tests__/configPersistence.test.ts src/components/oauth/__tests__/oauthAuthReadiness.test.ts src/utils/__tests__/desktopDeepLinkListener.test.ts`
  - `pnpm debug unit`
  - `pnpm test:coverage`
  - `pnpm --dir app exec eslint src/utils/desktopDeepLinkListener.ts src/utils/__tests__/desktopDeepLinkListener.test.ts test/OAuthTwitter.test.tsx test/OAuthGitHub.test.tsx test/OAuthDiscord.test.tsx test/OAuthLoginSection.test.tsx --ext .ts,.tsx --max-warnings=0`
- [x] Rust fmt/check (if changed): `cargo fmt --manifest-path ../Cargo.toml --all --check`, `cargo fmt --manifest-path src-tauri/Cargo.toml --all --check`, and `GGML_NATIVE=OFF pnpm rust:check` via pre-push hook
- [x] Tauri fmt/check (if changed): `cargo fmt --manifest-path app/src-tauri/Cargo.toml --all --check` and `GGML_NATIVE=OFF pnpm rust:check` via pre-push hook

### Validation Blocked
- `command:` N/A
- `error:` N/A
- `impact:` N/A. Local macOS/Tahoe `rust:check` requires the documented `GGML_NATIVE=OFF` workaround for `whisper-rs-sys`/`-mcpu=native`; validation passed with that env var.

### Behavior Changes
- Intended behavior change: first-launch Tauri OAuth launch waits for runtime readiness, and E2E deep-link simulation no longer blocks the WebDriver script on asynchronous auth completion.
- User-visible effect: desktop first-launch sign-in should complete reliably instead of racing core/auth initialization.

### Parity Contract
- Legacy behavior preserved: web OAuth redirects and Tauri `openUrl()` URL construction remain unchanged.
- Guard/fallback/dispatch parity checks: existing provider tests still cover Google/GitHub/Discord/Twitter web and Tauri branches; deep-link listener test covers readiness failure and async helper behavior.

### Duplicate / Superseded PR Handling
- Duplicate PR(s): #2247
- Canonical PR: this PR
- Resolution (closed/superseded/updated): #2247 could not be updated directly because `git push git@github.com:CodeGhost21/openhuman.git HEAD:cursor/a02-1689-signin-failed-first-time` was rejected with `Permission denied to YOMXXX`.


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

* **New Features**
  * OAuth sign-in adds a readiness gate that checks local runtime availability, surfaces clear user-facing messages when sign-in can’t proceed, and runs a short preflight on supported desktop builds before launching provider flows.
  * Deep-link sign-ins coordinate lifecycle steps for more reliable handling across environments; E2E helpers now treat auth deep links to bypass boot checks when appropriate.
  * Config lookup supports an E2E default core-mode fallback.

* **Tests**
  * Expanded tests for OAuth readiness, deep-link lifecycle, launch preparation, desktop flows, and config fallbacks.

<!-- 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/2267?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: Cursor <cursoragent@cursor.com>
Co-authored-by: 李冠辰 <liguanchen@xiaomi.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YOMXXX
2026-05-20 15:05:26 -07:00
committed by GitHub
co-authored by Ghost Scripter Cursor Steven Enamakel
parent 92682d06e5
commit d3a4ea3688
15 changed files with 582 additions and 66 deletions
+1 -1
View File
@@ -1 +1 @@
c90c8a330056286e7c0d05439ae3d4527fa4fafe
c90c8a330056286e7c0d05439ae3d4527fa4fafe
@@ -1,10 +1,16 @@
import debug from 'debug';
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { checkBackendHealthy } from '../../services/backendHealth';
import { getDeepLinkAuthState } from '../../store/deepLinkAuthState';
import {
beginDeepLinkAuthProcessing,
completeDeepLinkAuthProcessing,
getDeepLinkAuthState,
} from '../../store/deepLinkAuthState';
import type { OAuthProviderConfig } from '../../types/oauth';
import { IS_DEV } from '../../utils/config';
import { prepareOAuthLoginLaunch } from '../../utils/oauthAppVersionGate';
import { openUrl } from '../../utils/openUrl';
import { isTauri } from '../../utils/tauriCommands';
@@ -29,7 +35,36 @@ const OAUTH_PREFLIGHT_TIMEOUT_MS = 4_000;
const BACKEND_UNAVAILABLE_MESSAGE =
'OpenHuman cloud sign-in is temporarily unavailable. Please try again in a few minutes.';
const getOAuthStartupFailureMessage = (provider: OAuthProviderConfig): string => {
const log = debug('oauth:button');
const warnLog = debug('oauth:button:warn');
const errorLog = debug('oauth:button:error');
const SAFE_READINESS_STARTUP_MESSAGE_PREFIXES = [
'Finish choosing how OpenHuman runs',
'OpenHuman could not reach its local runtime',
];
const getSafeReadinessStartupMessage = (error: unknown): string | null => {
if (!(error instanceof Error)) {
return null;
}
const message = error.message.trim();
if (!message) {
return null;
}
return SAFE_READINESS_STARTUP_MESSAGE_PREFIXES.some(prefix => message.startsWith(prefix))
? message
: null;
};
const getOAuthStartupFailureMessage = (provider: OAuthProviderConfig, error?: unknown): string => {
const readinessMessage = getSafeReadinessStartupMessage(error);
if (readinessMessage) {
return readinessMessage;
}
if (provider.id === 'twitter') {
return 'Twitter/X sign-in could not start. Check that the Twitter OAuth app callback URL, client ID/secret, and requested scopes match the OpenHuman backend, then try again.';
}
@@ -84,14 +119,14 @@ const OAuthProviderButton = ({
void checkBackendHealthy()
.then(result => {
if (!result.healthy) {
console.warn(`[oauth-button][${provider.id}] ${label} probe backend unhealthy`, {
warnLog('[%s] %s probe -> backend unhealthy %o', provider.id, label, {
reason: result.reason,
latencyMs: result.latencyMs,
status: 'status' in result ? result.status : undefined,
});
setStartupError(BACKEND_UNAVAILABLE_MESSAGE);
} else {
console.debug(`[oauth-button][${provider.id}] ${label} probe backend healthy`, {
log('[%s] %s probe -> backend healthy %o', provider.id, label, {
status: result.status,
latencyMs: result.latencyMs,
});
@@ -100,7 +135,7 @@ const OAuthProviderButton = ({
.catch(err => {
// checkBackendHealthy already swallows network/abort errors and
// turns them into a result; reaching this branch is unexpected.
console.debug(`[oauth-button][${provider.id}] ${label} probe threw`, err);
log('[%s] %s probe threw %o', provider.id, label, err);
});
};
@@ -109,7 +144,7 @@ const OAuthProviderButton = ({
// and resetting first would briefly re-enable the button mid-redirect.
const skipDuringDeepLink = (label: string) => {
if (getDeepLinkAuthState().isProcessing) {
console.debug(`[oauth-button][${provider.id}] ${label} skip (deep-link processing)`);
log('[%s] %s - skip (deep-link processing)', provider.id, label);
return true;
}
return false;
@@ -119,7 +154,7 @@ const OAuthProviderButton = ({
// browser. On most platforms this lifts the loading state immediately.
const handleFocus = () => {
if (skipDuringDeepLink('focus')) return;
console.debug(`[oauth-button][${provider.id}] window focus reset isLoading`);
log('[%s] window focus -> reset isLoading', provider.id);
reset();
probeBackendOnReturn('focus');
};
@@ -130,13 +165,13 @@ const OAuthProviderButton = ({
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') return;
if (skipDuringDeepLink('visibilitychange')) return;
console.debug(`[oauth-button][${provider.id}] visibilitychange visible reset isLoading`);
log('[%s] visibilitychange visible -> reset isLoading', provider.id);
reset();
probeBackendOnReturn('visibilitychange');
};
const timer = window.setTimeout(() => {
console.debug(`[oauth-button][${provider.id}] timeout reset isLoading`);
log('[%s] timeout -> reset isLoading', provider.id);
reset();
// 90s with no deep-link is a strong "something went wrong" signal even
// if the user never refocused the app. Probe so we can attribute it.
@@ -161,29 +196,35 @@ const OAuthProviderButton = ({
if (externalDisabled || isLoading) return;
console.debug(`[oauth-button][${provider.id}] starting OAuth login (isTauri=${isTauri()})`);
log('[%s] starting OAuth login (isTauri=%s)', provider.id, isTauri());
setStartupError(null);
setIsLoading(true);
beginDeepLinkAuthProcessing();
browserOpenedRef.current = false;
// Fail-fast pre-flight: hitting `api.tinyhumans.ai/health` before opening
// the browser lets us catch Cloudflare 504s / DNS outages immediately
// (issue #1985) instead of sending the user into a system browser that
// lands on a gateway-error page with no path back into the app.
const preflight = await checkBackendHealthy({ timeoutMs: OAUTH_PREFLIGHT_TIMEOUT_MS });
if (!preflight.healthy) {
console.warn(`[oauth-button][${provider.id}] preflight → backend unhealthy`, {
reason: preflight.reason,
latencyMs: preflight.latencyMs,
status: 'status' in preflight ? preflight.status : undefined,
});
setStartupError(BACKEND_UNAVAILABLE_MESSAGE);
setIsLoading(false);
return;
}
try {
// Fail-fast pre-flight: hitting `api.tinyhumans.ai/health` before opening
// the browser lets us catch Cloudflare 504s / DNS outages immediately
// (issue #1985) instead of sending the user into a system browser that
// lands on a gateway-error page with no path back into the app.
const preflight = await checkBackendHealthy({ timeoutMs: OAUTH_PREFLIGHT_TIMEOUT_MS });
if (!preflight.healthy) {
warnLog('[%s] preflight -> backend unhealthy %o', provider.id, {
reason: preflight.reason,
latencyMs: preflight.latencyMs,
status: 'status' in preflight ? preflight.status : undefined,
});
completeDeepLinkAuthProcessing();
setStartupError(BACKEND_UNAVAILABLE_MESSAGE);
setIsLoading(false);
return;
}
if (isTauri()) {
await prepareOAuthLoginLaunch();
}
// Reuse the URL the preflight already resolved — `getBackendUrl()` may
// hit a Tauri IPC round-trip and the result hasn't changed within a
// single click handler.
@@ -206,9 +247,11 @@ const OAuthProviderButton = ({
window.location.href = loginUrl;
}
browserOpenedRef.current = true;
completeDeepLinkAuthProcessing();
} catch (error) {
const message = getOAuthStartupFailureMessage(provider);
console.error(`[oauth-button][${provider.id}] OAuth startup failed`, {
completeDeepLinkAuthProcessing();
const message = getOAuthStartupFailureMessage(provider, error);
errorLog('[%s] OAuth startup failed %o', provider.id, {
provider: provider.id,
providerName: provider.name,
reason: summarizeOAuthStartupError(error),
@@ -2,7 +2,12 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { checkBackendHealthy } from '../../../services/backendHealth';
import { getDeepLinkAuthState } from '../../../store/deepLinkAuthState';
import {
beginDeepLinkAuthProcessing,
completeDeepLinkAuthProcessing,
getDeepLinkAuthState,
} from '../../../store/deepLinkAuthState';
import { prepareOAuthLoginLaunch } from '../../../utils/oauthAppVersionGate';
import { openUrl } from '../../../utils/openUrl';
import { isTauri } from '../../../utils/tauriCommands';
import OAuthProviderButton from '../OAuthProviderButton';
@@ -11,9 +16,17 @@ vi.mock('../../../services/backendHealth', () => ({ checkBackendHealthy: vi.fn()
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
vi.mock('../../../utils/oauthAppVersionGate', () => ({
prepareOAuthLoginLaunch: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../../utils/tauriCommands', () => ({ isTauri: vi.fn() }));
vi.mock('../../../store/deepLinkAuthState', () => ({ getDeepLinkAuthState: vi.fn() }));
vi.mock('../../../store/deepLinkAuthState', () => ({
beginDeepLinkAuthProcessing: vi.fn(),
completeDeepLinkAuthProcessing: vi.fn(),
getDeepLinkAuthState: vi.fn(),
}));
const stubProvider = {
id: 'google' as const,
@@ -67,6 +80,10 @@ describe('OAuthProviderButton', () => {
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(beginDeepLinkAuthProcessing).toHaveBeenCalledTimes(1);
expect(completeDeepLinkAuthProcessing).toHaveBeenCalledTimes(1);
expect(prepareOAuthLoginLaunch).toHaveBeenCalledTimes(1);
expect(checkBackendHealthy).toHaveBeenCalledTimes(1);
expect(openUrl).toHaveBeenCalledWith(
expect.stringMatching(/^https:\/\/backend\.test\/auth\/google\/login(\?.*)?$/)
);
@@ -212,15 +229,26 @@ describe('OAuthProviderButton', () => {
'Twitter/X sign-in could not start. Check that the Twitter OAuth app callback URL, client ID/secret, and requested scopes match the OpenHuman backend, then try again.'
);
expect(screen.getByRole('button', { name: 'Twitter' })).toBeEnabled();
expect(console.error).toHaveBeenCalledWith(
'[oauth-button][twitter] OAuth startup failed',
expect.objectContaining({
provider: 'twitter',
providerName: 'Twitter',
guidance: expect.stringContaining('Twitter/X sign-in could not start'),
reason: expect.not.stringContaining('token=secret'),
})
);
expect(completeDeepLinkAuthProcessing).toHaveBeenCalledTimes(1);
});
it('surfaces safe readiness messages when the pre-launch readiness check fails', async () => {
const readinessMessage =
'OpenHuman could not reach its local runtime. Quit and reopen the app, then try signing in again.';
vi.mocked(prepareOAuthLoginLaunch).mockRejectedValueOnce(new Error(readinessMessage));
render(<OAuthProviderButton provider={stubProvider} />);
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(openUrl).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent(readinessMessage);
expect(screen.getByRole('button', { name: 'Google' })).toBeEnabled();
expect(completeDeepLinkAuthProcessing).toHaveBeenCalledTimes(1);
});
// --- Pre-flight + post-failure backend health probe (issue #1985) ---
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getCoreStateSnapshot } from '../../../lib/coreState/store';
import { bootCheckTransport } from '../../../services/bootCheckService';
import { testCoreRpcConnection } from '../../../services/coreRpcClient';
import { isTauri } from '../../../services/webviewAccountService';
import { getStoredCoreMode } from '../../../utils/configPersistence';
import {
oauthAuthReadinessUserMessage,
prepareOAuthLoginLaunch,
waitForOAuthAuthReadiness,
} from '../oauthAuthReadiness';
vi.mock('../../../lib/coreState/store', () => ({ getCoreStateSnapshot: vi.fn() }));
vi.mock('../../../services/coreRpcClient', () => ({
getCoreRpcUrl: vi.fn().mockResolvedValue('http://127.0.0.1:7788/rpc'),
testCoreRpcConnection: vi.fn(),
}));
vi.mock('../../../services/bootCheckService', () => ({
bootCheckTransport: { invokeCmd: vi.fn().mockResolvedValue(undefined), callRpc: vi.fn() },
}));
vi.mock('../../../utils/configPersistence', () => ({ getStoredCoreMode: vi.fn() }));
vi.mock('../../../services/webviewAccountService', () => ({
isTauri: vi.fn().mockReturnValue(true),
}));
describe('oauthAuthReadiness', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getStoredCoreMode).mockReturnValue('local');
vi.mocked(getCoreStateSnapshot).mockReturnValue({
isBootstrapping: false,
isReady: true,
snapshot: {
sessionToken: null,
auth: { isAuthenticated: false, userId: null, user: null, profileId: null },
currentUser: null,
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: { encryptionKey: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
},
teams: [],
teamMembersById: {},
teamInvitesById: {},
});
vi.mocked(testCoreRpcConnection).mockResolvedValue({ ok: true } as Response);
vi.mocked(isTauri).mockReturnValue(true);
});
it('returns core_mode_unset when BootCheckGate has not committed a mode', async () => {
vi.mocked(getStoredCoreMode).mockReturnValue(null);
const result = await waitForOAuthAuthReadiness(500);
expect(result).toEqual({ ready: false, reason: 'core_mode_unset' });
expect(oauthAuthReadinessUserMessage('core_mode_unset')).toMatch(/setup screen/i);
});
it('returns ready when core mode and ping are satisfied', async () => {
const result = await waitForOAuthAuthReadiness(2_000);
expect(result).toEqual({ ready: true });
expect(bootCheckTransport.invokeCmd).toHaveBeenCalledWith('start_core_process', {});
expect(testCoreRpcConnection).toHaveBeenCalled();
});
it('returns core_unreachable when ping never succeeds', async () => {
vi.mocked(testCoreRpcConnection).mockResolvedValue({ ok: false } as Response);
const result = await waitForOAuthAuthReadiness(600);
expect(result).toEqual({ ready: false, reason: 'core_unreachable' });
});
it('does not block first-login callbacks on CoreStateProvider bootstrap once ping succeeds', async () => {
vi.mocked(getCoreStateSnapshot).mockReturnValue({
isBootstrapping: true,
isReady: false,
snapshot: {
sessionToken: null,
auth: { isAuthenticated: false, userId: null, user: null, profileId: null },
currentUser: null,
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: { encryptionKey: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
},
teams: [],
teamMembersById: {},
teamInvitesById: {},
});
const result = await waitForOAuthAuthReadiness(3_000);
expect(result).toEqual({ ready: true });
});
it('does not start the local core on web builds', async () => {
vi.mocked(isTauri).mockReturnValue(false);
await waitForOAuthAuthReadiness(1_000);
expect(bootCheckTransport.invokeCmd).not.toHaveBeenCalled();
});
it('starts the local core only once during pre-launch readiness', async () => {
await prepareOAuthLoginLaunch();
expect(bootCheckTransport.invokeCmd).toHaveBeenCalledTimes(1);
expect(bootCheckTransport.invokeCmd).toHaveBeenCalledWith('start_core_process', {});
});
it('rejects with the readiness message when pre-launch core readiness fails', async () => {
vi.useFakeTimers();
vi.mocked(testCoreRpcConnection).mockResolvedValue({ ok: false } as Response);
try {
const launch = expect(prepareOAuthLoginLaunch()).rejects.toThrow(
oauthAuthReadinessUserMessage('core_unreachable')
);
await vi.advanceTimersByTimeAsync(8_000);
await launch;
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,136 @@
import debug from 'debug';
import { getCoreStateSnapshot } from '../../lib/coreState/store';
import { bootCheckTransport } from '../../services/bootCheckService';
import { getCoreRpcUrl, testCoreRpcConnection } from '../../services/coreRpcClient';
import { isTauri } from '../../services/webviewAccountService';
import { getStoredCoreMode } from '../../utils/configPersistence';
const logPrefix = '[oauth-auth-readiness]';
const log = debug('oauth:auth-readiness');
const warnLog = debug('oauth:auth-readiness:warn');
const DEFAULT_MAX_WAIT_MS = 30_000;
const POLL_MS = 200;
export type OAuthAuthReadinessFailure = 'core_mode_unset' | 'core_unreachable';
export type OAuthAuthReadinessResult =
| { ready: true }
| { ready: false; reason: OAuthAuthReadinessFailure };
const delay = (ms: number): Promise<void> =>
new Promise(resolve => {
setTimeout(resolve, ms);
});
async function pingCoreRpc(): Promise<boolean> {
try {
const rpcUrl = await getCoreRpcUrl();
const response = await testCoreRpcConnection(rpcUrl);
return response.ok;
} catch (err) {
log(`${logPrefix} core.ping probe failed`, err);
return false;
}
}
async function ensureLocalCoreProcessStarted(): Promise<void> {
if (!isTauri()) {
return;
}
if (getStoredCoreMode() !== 'local') {
return;
}
try {
await bootCheckTransport.invokeCmd('start_core_process', {});
log(`${logPrefix} start_core_process invoked`);
} catch (err) {
log(`${logPrefix} start_core_process skipped or failed`, err);
}
}
/**
* Block OAuth sign-in until the BootCheckGate has committed a core mode,
* and the embedded core answers `core.ping`.
*
* First-launch sign-in often failed with a generic "Sign-in failed" because
* the deep-link handler only waited ~1.5s while `isBootstrapping` stayed true
* behind the runtime picker, then called RPC against a core that was not up yet.
* The login callback itself can proceed before CoreStateProvider completes its
* first snapshot refresh; requiring that UI bootstrap here deadlocks some E2E
* and first-login paths where the callback is what creates the session.
*/
export async function waitForOAuthAuthReadiness(
maxWaitMs = DEFAULT_MAX_WAIT_MS
): Promise<OAuthAuthReadinessResult> {
const deadline = Date.now() + maxWaitMs;
let sawCoreMode = false;
while (Date.now() < deadline) {
const mode = getStoredCoreMode();
if (mode) {
sawCoreMode = true;
break;
}
await delay(POLL_MS);
}
if (!sawCoreMode) {
warnLog(`${logPrefix} timed out waiting for core mode selection`);
return { ready: false, reason: 'core_mode_unset' };
}
await ensureLocalCoreProcessStarted();
while (Date.now() < deadline) {
const coreState = getCoreStateSnapshot();
if (await pingCoreRpc()) {
log(`${logPrefix} ready`, {
authBootstrapComplete: !coreState.isBootstrapping,
hasSessionToken: Boolean(coreState.snapshot.sessionToken),
coreMode: getStoredCoreMode(),
});
return { ready: true };
}
await delay(POLL_MS);
}
if (!(await pingCoreRpc())) {
warnLog(`${logPrefix} core RPC unreachable after ${maxWaitMs}ms`);
return { ready: false, reason: 'core_unreachable' };
}
return { ready: true };
}
export function oauthAuthReadinessUserMessage(reason: OAuthAuthReadinessFailure): string {
switch (reason) {
case 'core_mode_unset':
return (
'Finish choosing how OpenHuman runs (tap Continue on the setup screen), ' +
'then try signing in again.'
);
case 'core_unreachable':
return (
'OpenHuman could not reach its local runtime. Quit and reopen the app, ' +
'then try signing in again.'
);
default:
return 'Sign-in is still starting up. Wait a few seconds and try again.';
}
}
/**
* Lightweight preflight before opening the system browser for OAuth.
* Blocks browser launch when the local auth runtime is not ready yet.
* `waitForOAuthAuthReadiness()` starts the local core when needed.
*/
export async function prepareOAuthLoginLaunch(): Promise<void> {
const quick = await waitForOAuthAuthReadiness(8_000);
if (!quick.ready) {
warnLog(`${logPrefix} pre-launch readiness`, quick);
throw new Error(oauthAuthReadinessUserMessage(quick.reason));
}
}
@@ -417,5 +417,23 @@ describe('configPersistence', () => {
clearStoredCoreMode();
expect(getStoredCoreMode()).toBeNull();
});
it('falls back to the E2E default local mode when no marker has been written', async () => {
vi.resetModules();
vi.doMock('../config', () => ({
CORE_RPC_URL: 'http://127.0.0.1:7788/rpc',
E2E_DEFAULT_CORE_MODE: 'local',
}));
try {
localStorage.removeItem(MODE_STORAGE_KEY);
const mod = await import('../configPersistence');
expect(mod.getStoredCoreMode()).toBe('local');
} finally {
vi.doUnmock('../config');
vi.resetModules();
}
});
});
});
@@ -29,6 +29,19 @@ vi.mock('../../lib/coreState/store', () => ({
patchCoreStateSnapshot: vi.fn(),
}));
const waitForOAuthAuthReadiness = vi.hoisted(() =>
vi.fn().mockResolvedValue({ ready: true as const })
);
vi.mock('../oauthAppVersionGate', async importOriginal => {
const actual = await importOriginal<typeof import('../oauthAppVersionGate')>();
return {
...actual,
waitForOAuthAuthReadiness,
oauthAuthReadinessUserMessage: (reason: string) => `blocked:${reason}`,
};
});
const windowControls = vi.hoisted(() => ({
show: vi.fn().mockResolvedValue(undefined),
unminimize: vi.fn().mockResolvedValue(undefined),
@@ -42,6 +55,10 @@ describe('desktopDeepLinkListener', () => {
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(getCurrent).mockResolvedValue(null);
vi.mocked(onOpenUrl).mockResolvedValue(() => {});
waitForOAuthAuthReadiness.mockReset();
waitForOAuthAuthReadiness.mockResolvedValue({ ready: true });
vi.mocked(storeSession).mockReset();
vi.mocked(storeSession).mockResolvedValue(undefined);
windowControls.show.mockClear();
windowControls.unminimize.mockClear();
windowControls.setFocus.mockClear();
@@ -104,6 +121,19 @@ describe('desktopDeepLinkListener', () => {
expect(state.isProcessing).toBe(false);
});
it('surfaces readiness failures instead of a generic sign-in error', async () => {
waitForOAuthAuthReadiness.mockResolvedValueOnce({ ready: false, reason: 'core_mode_unset' });
vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']);
await setupDesktopDeepLinkListener();
const state = getDeepLinkAuthState();
expect(state.errorMessage).toBe('blocked:core_mode_unset');
expect(state.isProcessing).toBe(false);
expect(storeSession).not.toHaveBeenCalled();
});
it('keeps requiresAppDataReset false for non-decryption auth failures', async () => {
vi.mocked(storeSession).mockRejectedValueOnce(new Error('network down'));
@@ -117,6 +147,34 @@ describe('desktopDeepLinkListener', () => {
expect(state.errorMessage).toBe('Sign-in failed. Please try again.');
});
it('does not make the E2E deep-link helper wait for auth readiness', async () => {
let resolveReadiness!: (_value: { ready: true }) => void;
waitForOAuthAuthReadiness.mockReturnValueOnce(
new Promise<{ ready: true }>(resolve => {
resolveReadiness = resolve;
})
);
await setupDesktopDeepLinkListener();
const simulateDeepLink = (
window as Window & { __simulateDeepLink?: (url: string) => Promise<void> }
).__simulateDeepLink;
expect(simulateDeepLink).toBeTypeOf('function');
await expect(simulateDeepLink!('openhuman://auth?token=abc&key=auth')).resolves.toBeUndefined();
expect(storeSession).not.toHaveBeenCalled();
await new Promise(resolve => setTimeout(resolve, 0));
expect(waitForOAuthAuthReadiness).toHaveBeenCalledTimes(1);
resolveReadiness({ ready: true });
await waitForAuthSettled();
expect(storeSession).toHaveBeenCalledWith('abc', {});
expect(getDeepLinkAuthState().isProcessing).toBe(false);
});
it('sanitizes provider and error code values from OAuth error deep links', async () => {
const oauthErrorEvents: CustomEvent[] = [];
window.addEventListener('oauth:error', event => {
+7 -2
View File
@@ -4,7 +4,7 @@
* Handles storing/retrieving user preferences like RPC URL using
* localStorage (web) or Tauri store (desktop).
*/
import { CORE_RPC_URL } from './config';
import { CORE_RPC_URL, E2E_DEFAULT_CORE_MODE } from './config';
import { isTauri } from './tauriCommands';
// Storage key for RPC URL preference
@@ -239,10 +239,15 @@ export function clearStoredCoreToken(): void {
export function getStoredCoreMode(): 'local' | 'cloud' | null {
try {
const stored = localStorage.getItem(CORE_MODE_STORAGE_KEY)?.trim();
if (stored === 'local' || stored === 'cloud') return stored;
if (stored) {
if (stored === 'local' || stored === 'cloud') return stored;
return null;
}
} catch {
console.warn('[configPersistence] Unable to access localStorage');
}
if (E2E_DEFAULT_CORE_MODE === 'local') return 'local';
return null;
}
+16 -20
View File
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { getCoreStateSnapshot, patchCoreStateSnapshot } from '../lib/coreState/store';
import { patchCoreStateSnapshot } from '../lib/coreState/store';
import { consumeLoginToken } from '../services/api/authApi';
import {
beginDeepLinkAuthProcessing,
@@ -10,7 +10,11 @@ import {
failDeepLinkAuthProcessing,
} from '../store/deepLinkAuthState';
import { BILLING_DASHBOARD_URL } from './links';
import { evaluateOAuthAppVersionGate } from './oauthAppVersionGate';
import {
evaluateOAuthAppVersionGate,
oauthAuthReadinessUserMessage,
waitForOAuthAuthReadiness,
} from './oauthAppVersionGate';
import { openUrl } from './openUrl';
import { storeSession } from './tauriCommands';
import { isTauri as coreIsTauri } from './tauriCommands/common';
@@ -71,22 +75,6 @@ const focusMainWindow = async () => {
}
};
const waitForAuthReadiness = async (maxAttempts = 10, delayMs = 150) => {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const coreState = getCoreStateSnapshot();
if (!coreState.isBootstrapping || coreState.snapshot.sessionToken) {
console.log('[DeepLink][auth] app ready', {
attempt,
hasToken: Boolean(coreState.snapshot.sessionToken),
authBootstrapComplete: !coreState.isBootstrapping,
});
return;
}
await new Promise(resolve => setTimeout(resolve, delayMs));
}
console.warn('[DeepLink][auth] readiness timeout; continuing');
};
const applySessionToken = async (sessionToken: string): Promise<void> => {
await storeSession(sessionToken, {});
patchCoreStateSnapshot({ snapshot: { sessionToken } });
@@ -109,7 +97,13 @@ const handleAuthDeepLink = async (parsed: URL) => {
try {
await focusMainWindow();
await waitForAuthReadiness();
const readiness = await waitForOAuthAuthReadiness();
if (!readiness.ready) {
console.warn('[DeepLink][auth] OAuth readiness gate blocked login', readiness);
failDeepLinkAuthProcessing(oauthAuthReadinessUserMessage(readiness.reason));
return;
}
const sessionToken = key === 'auth' ? token : await consumeLoginToken(token);
await applySessionToken(sessionToken);
@@ -334,7 +328,9 @@ export const setupDesktopDeepLinkListener = async () => {
// window.__simulateDeepLink('openhuman://oauth/success?integrationId=69cafd0b103bd070232d3223&provider=notion')
// window.__simulateDeepLink('openhuman://oauth/success?integrationId=69cafd0b103bd070232d3223&skillId=discord')
const win = window as Window & { __simulateDeepLink?: (url: string) => Promise<void> };
win.__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]);
win.__simulateDeepLink = async (url: string) => {
void handleDeepLinkUrls([url]);
};
}
} catch (err) {
console.error('[DeepLink] Setup failed:', err);
+15
View File
@@ -1,9 +1,24 @@
import { getVersion } from '@tauri-apps/api/app';
import {
type OAuthAuthReadinessFailure,
type OAuthAuthReadinessResult,
oauthAuthReadinessUserMessage,
prepareOAuthLoginLaunch,
waitForOAuthAuthReadiness,
} from '../components/oauth/oauthAuthReadiness';
import { LATEST_APP_DOWNLOAD_URL, MINIMUM_SUPPORTED_APP_VERSION } from './config';
import { isVersionAtLeast, parseSemverParts } from './semver';
import { isTauri } from './tauriCommands/common';
export {
oauthAuthReadinessUserMessage,
prepareOAuthLoginLaunch,
waitForOAuthAuthReadiness,
type OAuthAuthReadinessFailure,
type OAuthAuthReadinessResult,
};
export type OAuthAppVersionGateResult =
| { ok: true }
| { ok: false; current: string; minimum: string; downloadUrl: string };
+17 -1
View File
@@ -21,10 +21,17 @@ import { renderWithProviders } from '../src/test/test-utils';
// Module mocks
// ---------------------------------------------------------------------------
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
const {
mockGetBackendUrl,
mockOpenUrl,
mockIsTauri,
mockPrepareOAuthLoginLaunch,
mockCheckBackendHealthy,
} = vi.hoisted(() => ({
mockGetBackendUrl: vi.fn(),
mockOpenUrl: vi.fn(),
mockIsTauri: vi.fn(),
mockPrepareOAuthLoginLaunch: vi.fn(),
// Default to a healthy backend so the pre-flight in OAuthProviderButton
// (added for issue #1985) doesn't short-circuit the OAuth flow these
// tests exercise.
@@ -49,6 +56,15 @@ vi.mock('../src/utils/tauriCommands', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, isTauri: mockIsTauri };
});
vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch };
});
beforeEach(() => {
mockPrepareOAuthLoginLaunch.mockReset();
mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined);
});
// ---------------------------------------------------------------------------
// Helpers
+17 -1
View File
@@ -21,10 +21,17 @@ import { renderWithProviders } from '../src/test/test-utils';
// Module mocks
// ---------------------------------------------------------------------------
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
const {
mockGetBackendUrl,
mockOpenUrl,
mockIsTauri,
mockPrepareOAuthLoginLaunch,
mockCheckBackendHealthy,
} = vi.hoisted(() => ({
mockGetBackendUrl: vi.fn(),
mockOpenUrl: vi.fn(),
mockIsTauri: vi.fn(),
mockPrepareOAuthLoginLaunch: vi.fn(),
// Default to a healthy backend so the pre-flight in OAuthProviderButton
// (added for issue #1985) doesn't short-circuit the OAuth flow these
// tests exercise.
@@ -49,6 +56,15 @@ vi.mock('../src/utils/tauriCommands', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, isTauri: mockIsTauri };
});
vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch };
});
beforeEach(() => {
mockPrepareOAuthLoginLaunch.mockReset();
mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined);
});
// ---------------------------------------------------------------------------
// Helpers
+17 -1
View File
@@ -24,10 +24,17 @@ import { renderWithProviders } from '../src/test/test-utils';
// (which are hoisted to the top of the file by Vitest).
// ---------------------------------------------------------------------------
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
const {
mockGetBackendUrl,
mockOpenUrl,
mockIsTauri,
mockPrepareOAuthLoginLaunch,
mockCheckBackendHealthy,
} = vi.hoisted(() => ({
mockGetBackendUrl: vi.fn(),
mockOpenUrl: vi.fn(),
mockIsTauri: vi.fn(),
mockPrepareOAuthLoginLaunch: vi.fn(),
// Default to a healthy backend so the pre-flight in OAuthProviderButton
// (added for issue #1985) doesn't short-circuit the OAuth flow these
// tests exercise.
@@ -54,6 +61,15 @@ vi.mock('../src/utils/tauriCommands', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, isTauri: mockIsTauri };
});
vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch };
});
beforeEach(() => {
mockPrepareOAuthLoginLaunch.mockReset();
mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined);
});
// IS_DEV is set to `true` by the global setup mock of '../utils/config'
+17 -1
View File
@@ -21,10 +21,17 @@ import { renderWithProviders } from '../src/test/test-utils';
// Module mocks
// ---------------------------------------------------------------------------
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
const {
mockGetBackendUrl,
mockOpenUrl,
mockIsTauri,
mockPrepareOAuthLoginLaunch,
mockCheckBackendHealthy,
} = vi.hoisted(() => ({
mockGetBackendUrl: vi.fn(),
mockOpenUrl: vi.fn(),
mockIsTauri: vi.fn(),
mockPrepareOAuthLoginLaunch: vi.fn(),
// Default to a healthy backend so the pre-flight in OAuthProviderButton
// (added for issue #1985) doesn't short-circuit the OAuth flow these
// tests exercise.
@@ -49,6 +56,15 @@ vi.mock('../src/utils/tauriCommands', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, isTauri: mockIsTauri };
});
vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch };
});
beforeEach(() => {
mockPrepareOAuthLoginLaunch.mockReset();
mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined);
});
// ---------------------------------------------------------------------------
// Helpers
+15
View File
@@ -81,6 +81,15 @@ function supportsWebDriverScriptExecute(): boolean {
return false;
}
function isAuthDeepLink(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'openhuman:' && parsed.hostname === 'auth';
} catch {
return false;
}
}
/**
* When WebDriver can execute JS in the app WebView, dispatch the same URLs as the
* deep-link plugin via `window.__simulateDeepLink` (see desktopDeepLinkListener).
@@ -200,6 +209,12 @@ export async function triggerDeepLink(url: string): Promise<void> {
platform: process.platform,
});
if (isAuthDeepLink(url)) {
await dismissBootCheckGateIfVisibleInline().catch(err => {
deepLinkDebug('pre-auth deep-link BootCheckGate dismiss failed (continuing):', err);
});
}
if (typeof browser !== 'undefined') {
// Strategy 1: WebView simulate — the only reliable path on the unified
// CEF/Appium-Chromium harness. If it succeeds we're done; if it throws