From 5551e1e0aab2c3dd5ddb1c7b533f454db325cf13 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 8 Apr 2026 04:26:19 +0530 Subject: [PATCH] Fix/365 enforce latest oauth gate from 351 (#421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: display app version in settings panel * fix(onboarding): auto-refresh accessibility state after grant (#351) * style(onboarding): apply formatter for issue #351 fix * fix(onboarding): ESLint + typed mock for ScreenPermissionsStep; clear flag in handler Consolidate tauriCommands imports and drop redundant mock cast. Handle granted accessibility in focus/visibility callback instead of a follow-up effect. Made-with: Cursor * fix(release): gate OAuth deep links on minimum app version (#365) - Add semver helpers and VITE_MINIMUM_SUPPORTED_APP_VERSION / download URL in app config - Block openhuman://oauth/success when desktop build is below minimum; enqueue error, open latest-release URL, dispatch oauth:stale-app - Pass new Vite env vars through release.yml and build-windows.yml Build frontend - Document policy in docs/RELEASE_POLICY.md; note vars in app/.env.example Closes #365 Made-with: Cursor * fix: import React in SkillSetupWizard component * fix: address CodeRabbit review (OAuth gate, semver, logging) - Anchor semver regex to full string; arrow-style exports; tests for bad inputs - Never throw from evaluateOAuthAppVersionGate; try/catch in deep link + omit raw URL from error logs - Document build-time vs runtime policy in config JSDoc and RELEASE_POLICY - Remove unused React import in SkillSetupWizard (tsc) Made-with: Cursor * fix: CodeRabbit — fail-closed OAuth gate, tauri-action Vite env, runbook - Block OAuth when minimum is set but getVersion fails or version is unparseable - Pass VITE_MINIMUM_* through tauri-action env (release + Windows) so bundles match yarn build - Expand RELEASE_POLICY: artifact retirement, dual workflow env note - Friendlier copy when current version is unknown Made-with: Cursor --- .github/workflows/build-windows.yml | 4 ++ .github/workflows/release.yml | 6 +++ app/.env.example | 5 ++ app/src/utils/config.ts | 19 +++++++ app/src/utils/desktopDeepLinkListener.ts | 57 ++++++++++++++++++++- app/src/utils/oauthAppVersionGate.ts | 64 ++++++++++++++++++++++++ app/src/utils/semver.test.ts | 26 ++++++++++ app/src/utils/semver.ts | 32 ++++++++++++ docs/RELEASE_POLICY.md | 34 +++++++++++++ 9 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 app/src/utils/oauthAppVersionGate.ts create mode 100644 app/src/utils/semver.test.ts create mode 100644 app/src/utils/semver.ts create mode 100644 docs/RELEASE_POLICY.md diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index e504f5629..cef0db1f8 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -63,6 +63,8 @@ jobs: VITE_BACKEND_URL: ${{ vars.BASE_URL }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} VITE_DEBUG: ${{ vars.VITE_DEBUG }} + VITE_MINIMUM_SUPPORTED_APP_VERSION: ${{ vars.VITE_MINIMUM_SUPPORTED_APP_VERSION }} + VITE_LATEST_APP_DOWNLOAD_URL: ${{ vars.VITE_LATEST_APP_DOWNLOAD_URL }} - name: Resolve core manifest and binary names id: core-paths @@ -129,6 +131,8 @@ jobs: id: tauri-build env: BASE_URL: ${{ vars.BASE_URL }} + VITE_MINIMUM_SUPPORTED_APP_VERSION: ${{ vars.VITE_MINIMUM_SUPPORTED_APP_VERSION }} + VITE_LATEST_APP_DOWNLOAD_URL: ${{ vars.VITE_LATEST_APP_DOWNLOAD_URL }} with: projectPath: app args: -c ${{ steps.config-overrides.outputs.json }} --target x86_64-pc-windows-msvc diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab6139e3c..f9a017044 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -309,6 +309,9 @@ jobs: VITE_BACKEND_URL: ${{ vars.BASE_URL }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} VITE_DEBUG: ${{ vars.VITE_DEBUG }} + # OAuth guardrails (#365): block openhuman://oauth/success on outdated desktop builds. + VITE_MINIMUM_SUPPORTED_APP_VERSION: ${{ vars.VITE_MINIMUM_SUPPORTED_APP_VERSION }} + VITE_LATEST_APP_DOWNLOAD_URL: ${{ vars.VITE_LATEST_APP_DOWNLOAD_URL }} - name: Resolve core manifest and binary names id: core-paths @@ -390,6 +393,9 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} WITH_UPDATER: "true" + # Must match standalone "Build frontend" so tauri-action's beforeBuildCommand embeds the gate. + VITE_MINIMUM_SUPPORTED_APP_VERSION: ${{ vars.VITE_MINIMUM_SUPPORTED_APP_VERSION }} + VITE_LATEST_APP_DOWNLOAD_URL: ${{ vars.VITE_LATEST_APP_DOWNLOAD_URL }} with: projectPath: app args: -c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }} diff --git a/app/.env.example b/app/.env.example index 5645c62c2..c14356ce1 100644 --- a/app/.env.example +++ b/app/.env.example @@ -28,3 +28,8 @@ VITE_DEV_FORCE_ONBOARDING=false # [optional] Client-side timeout for skill callTool/triggerSync (seconds; default 120, max 3600). # Should match OPENHUMAN_TOOL_TIMEOUT_SECS on the core when set. # VITE_TOOL_TIMEOUT_SECS= + +# [optional] Minimum desktop app semver to complete OAuth deep links (openhuman://oauth/success). Leave unset in dev. +# VITE_MINIMUM_SUPPORTED_APP_VERSION=0.51.0 +# [optional] Download page when OAuth is blocked due to an outdated build (default: GitHub releases/latest). +# VITE_LATEST_APP_DOWNLOAD_URL=https://github.com/tinyhumansai/openhuman/releases/latest diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index cbd54c6ab..0a6c62d09 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -44,3 +44,22 @@ export const DEV_JWT_TOKEN = import.meta.env.DEV : undefined; export const APP_VERSION = packageJson.version; + +/** + * Minimum **desktop app** semver required for OAuth deep-link completion (`openhuman://oauth/success`). + * + * **Build-time embedding:** This value is baked into each shipped installer. Raising the floor for + * users already on an older build requires them to install a **new** release (or use in-app update + * when available)—changing CI vars alone does not retrofit existing binaries. For a fleet-wide + * minimum that can move without a new app build, add a runtime policy endpoint later and consult it + * here with this constant as fallback only. + * + * Set in production builds (e.g. GitHub Actions `vars`). Empty = no gate (default for local dev). + */ +export const MINIMUM_SUPPORTED_APP_VERSION = + (import.meta.env.VITE_MINIMUM_SUPPORTED_APP_VERSION as string | undefined)?.trim() ?? ''; + +/** Recovery link opened when OAuth is blocked due to an outdated app build (build-time default; override via CI). */ +export const LATEST_APP_DOWNLOAD_URL = + (import.meta.env.VITE_LATEST_APP_DOWNLOAD_URL as string | undefined)?.trim() || + 'https://github.com/tinyhumansai/openhuman/releases/latest'; diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 3fa1d65a5..4658bdf5f 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -7,6 +7,9 @@ import { skillManager } from '../lib/skills/manager'; import { emitSkillStateChange } from '../lib/skills/skillEvents'; import { startSkill } from '../lib/skills/skillsApi'; import { consumeLoginToken } from '../services/api/authApi'; +import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; +import { evaluateOAuthAppVersionGate } from './oauthAppVersionGate'; +import { openUrl } from './openUrl'; import { storeSession } from './tauriCommands'; const focusMainWindow = async () => { @@ -116,7 +119,56 @@ const handleOAuthDeepLink = async (parsed: URL) => { const skillId = parsed.searchParams.get('skillId'); if (!integrationId || !skillId) { - console.error('[DeepLink] OAuth success missing integrationId or skillId', parsed.href); + // Do not log full URL — query can contain secrets (e.g. clientKey). + console.error('[DeepLink] OAuth success missing integrationId or skillId'); + return; + } + + let versionGate: Awaited>; + try { + versionGate = await evaluateOAuthAppVersionGate(); + } catch (gateErr) { + // Avoid bubbling: outer handler logs the raw URL and would leak query secrets. + console.warn('[DeepLink] OAuth version gate failed; continuing OAuth', gateErr); + versionGate = { ok: true }; + } + + if (!versionGate.ok) { + const msg = + versionGate.current === 'unknown' + ? `OpenHuman could not verify this build against the minimum required for OAuth (${versionGate.minimum}). Install the latest release, then try connecting again.` + : `This OpenHuman build (${versionGate.current}) is older than the minimum required for OAuth (${versionGate.minimum}). Install the latest release, then try connecting again.`; + try { + await openUrl(versionGate.downloadUrl); + } catch (e) { + console.warn('[DeepLink] Could not open latest release URL', e); + } + enqueueError({ + id: crypto.randomUUID(), + timestamp: Date.now(), + source: 'manual', + title: 'Update OpenHuman to finish OAuth', + message: msg, + sentryEvent: buildManualSentryEvent( + { type: 'OAuthStaleAppVersion', value: `${versionGate.current}<${versionGate.minimum}` }, + { + component: 'desktopDeepLinkListener', + current: versionGate.current, + minimum: versionGate.minimum, + } + ), + }); + window.dispatchEvent( + new CustomEvent('oauth:stale-app', { + detail: { + current: versionGate.current, + minimum: versionGate.minimum, + downloadUrl: versionGate.downloadUrl, + skillId, + integrationId, + }, + }) + ); return; } @@ -194,7 +246,8 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { break; } } catch (error) { - console.error('[DeepLink] Failed to handle deep link URL:', url, error); + // Avoid logging full `url` — OAuth callbacks can include sensitive query params. + console.error('[DeepLink] Failed to handle deep link:', error); } }; diff --git a/app/src/utils/oauthAppVersionGate.ts b/app/src/utils/oauthAppVersionGate.ts new file mode 100644 index 000000000..c5c1d9b52 --- /dev/null +++ b/app/src/utils/oauthAppVersionGate.ts @@ -0,0 +1,64 @@ +import { getVersion } from '@tauri-apps/api/app'; +import { isTauri } from '@tauri-apps/api/core'; + +import { LATEST_APP_DOWNLOAD_URL, MINIMUM_SUPPORTED_APP_VERSION } from './config'; +import { isVersionAtLeast, parseSemverParts } from './semver'; + +export type OAuthAppVersionGateResult = + | { ok: true } + | { ok: false; current: string; minimum: string; downloadUrl: string }; + +function block(minimum: string, current: string): OAuthAppVersionGateResult { + return { ok: false, current, minimum, downloadUrl: LATEST_APP_DOWNLOAD_URL }; +} + +/** + * When `VITE_MINIMUM_SUPPORTED_APP_VERSION` is set (CI/production), block OAuth + * `openhuman://oauth/success` handling if the running desktop build is older. + * Prevents completing Gmail (and other) OAuth on deprecated app binaries. + * + * When a minimum is configured, fails **closed** if the app version cannot be + * determined or parsed (never silently allows OAuth on unknown versions). + */ +export async function evaluateOAuthAppVersionGate(): Promise { + const minimum = MINIMUM_SUPPORTED_APP_VERSION.trim(); + try { + if (!minimum) { + return { ok: true }; + } + if (!parseSemverParts(minimum)) { + console.warn('[oauth-app-version] invalid MINIMUM_SUPPORTED_APP_VERSION; gate disabled'); + return { ok: true }; + } + if (!isTauri()) { + return { ok: true }; + } + + let current: string; + try { + current = await getVersion(); + } catch (e) { + console.warn('[oauth-app-version] getVersion failed; blocking OAuth', e); + return block(minimum, 'unknown'); + } + + if (!parseSemverParts(current)) { + console.warn('[oauth-app-version] unparseable app version; blocking OAuth', current); + return block(minimum, current); + } + + if (isVersionAtLeast(current, minimum)) { + return { ok: true }; + } + + console.warn('[oauth-app-version] blocked OAuth success deep link', { current, minimum }); + return block(minimum, current); + } catch (e) { + // Never throw: outer deep-link handler must not receive errors that could log the raw URL. + console.warn('[oauth-app-version] unexpected error', e); + if (!minimum) { + return { ok: true }; + } + return block(minimum, 'unknown'); + } +} diff --git a/app/src/utils/semver.test.ts b/app/src/utils/semver.test.ts new file mode 100644 index 000000000..8282103a3 --- /dev/null +++ b/app/src/utils/semver.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { compareSemver, isVersionAtLeast, parseSemverParts } from './semver'; + +describe('semver', () => { + it('rejects malformed or suffixed versions (full-string match)', () => { + expect(parseSemverParts('0.51.x')).toBeNull(); + expect(parseSemverParts('1.2beta')).toBeNull(); + expect(parseSemverParts('0.51.0foo')).toBeNull(); + expect(parseSemverParts('0.51.0-rc.1')).toBeNull(); + expect(parseSemverParts('0.51.0')).not.toBeNull(); + }); + + it('compares dotted versions', () => { + expect(compareSemver('0.46.0', '0.47.0')).toBeLessThan(0); + expect(compareSemver('0.47.0', '0.47.0')).toBe(0); + expect(compareSemver('0.48.0', '0.47.0')).toBeGreaterThan(0); + expect(compareSemver('v1.2.3', '1.2.3')).toBe(0); + }); + + it('isVersionAtLeast', () => { + expect(isVersionAtLeast('0.47.0', '0.47.0')).toBe(true); + expect(isVersionAtLeast('0.48.0', '0.47.0')).toBe(true); + expect(isVersionAtLeast('0.46.0', '0.47.0')).toBe(false); + }); +}); diff --git a/app/src/utils/semver.ts b/app/src/utils/semver.ts new file mode 100644 index 000000000..dc3788773 --- /dev/null +++ b/app/src/utils/semver.ts @@ -0,0 +1,32 @@ +/** + * Minimal semver comparison for dotted numeric versions (e.g. 0.51.0). + * Full-string match only — rejects suffixes like `0.51.x` or `1.2beta`. + */ + +const SEMVER_NUMERIC = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/; + +export const parseSemverParts = (version: string): [number, number, number] | null => { + const trimmed = version.trim(); + const m = trimmed.match(SEMVER_NUMERIC); + if (!m) return null; + return [parseInt(m[1], 10), parseInt(m[2] ?? '0', 10), parseInt(m[3] ?? '0', 10)]; +}; + +/** Compare a/b; returns negative if a < b, positive if a > b, 0 if equal or unparseable. */ +export const compareSemver = (a: string, b: string): number => { + const pa = parseSemverParts(a); + const pb = parseSemverParts(b); + if (!pa || !pb) return 0; + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1; + } + return 0; +}; + +/** True if current >= minimum (both must parse; otherwise false). */ +export const isVersionAtLeast = (current: string, minimum: string): boolean => { + const minParts = parseSemverParts(minimum); + const curParts = parseSemverParts(current); + if (!minParts || !curParts) return false; + return compareSemver(current, minimum) >= 0; +}; diff --git a/docs/RELEASE_POLICY.md b/docs/RELEASE_POLICY.md new file mode 100644 index 000000000..eea58969f --- /dev/null +++ b/docs/RELEASE_POLICY.md @@ -0,0 +1,34 @@ +# Release policy: latest desktop builds and OAuth + +This runbook describes how we avoid users completing **OAuth** (including **Gmail**) on **outdated desktop installers** while the canonical flow is the **latest** release. + +## Distribution + +- **GitHub Releases** for [tinyhumansai/openhuman](https://github.com/tinyhumansai/openhuman/releases) are the primary source for desktop builds. +- The **Tauri updater** endpoint (see `scripts/prepareTauriConfig.js` and release workflows) should point users at the current release artifacts. +- **Retiring old stable artifacts:** When dropping a release line, remove or hide obsolete installer assets on **GitHub Releases**, update **website / CDN** download links to **releases/latest** (or current), refresh the **updater manifest** (e.g. Gist / `latest.json`) so it does not point users at deprecated builds, and spot-check that old direct URLs are **redirected, 404, or 410** where appropriate. Verification: try known-old asset URLs from docs or bookmarks and confirm they no longer deliver primary install paths. + +## Minimum app version for OAuth + +Production web builds embed a **minimum supported app semver** at **build time** so OAuth deep links cannot complete on deprecated binaries. Each installer carries the floor that was set when that build was produced; raising the floor for users who never upgrade requires a **new** release they install (or in-app update). Optional future work: enforce a moving minimum via a **runtime** API with the bundled value as fallback only. + +| Variable | Purpose | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `VITE_MINIMUM_SUPPORTED_APP_VERSION` | e.g. `0.51.0` — desktop app must be **≥** this to finish `openhuman://oauth/success`. | +| `VITE_LATEST_APP_DOWNLOAD_URL` | Optional; defaults to `https://github.com/tinyhumansai/openhuman/releases/latest`. Opened when the gate blocks OAuth. | + +Configure these as **GitHub Actions variables**. They must be present on **both** the standalone **`yarn build`** step and the **`tauri-apps/tauri-action`** step env in `.github/workflows/release.yml` and `build-windows.yml` so the Vite bundle embedded in shipped installers includes the gate. Leave `VITE_MINIMUM_SUPPORTED_APP_VERSION` **unset** for local dev (gate disabled). + +Implementation: `app/src/utils/oauthAppVersionGate.ts`, `app/src/utils/desktopDeepLinkListener.ts`. + +## Gmail / Google Cloud OAuth + +- **Redirect URIs** in Google Cloud Console must match the **current** backend + tunnel callback paths. +- The desktop scheme (`openhuman://`) is stable; the **installed binary** must meet the minimum version when `VITE_MINIMUM_SUPPORTED_APP_VERSION` is set. + +## Release checklist (avoid regressions) + +1. Bump `app/package.json` and `app/src-tauri/tauri.conf.json` (and root `Cargo.toml` / core) per existing version workflows. +2. When dropping support for older installs, set **`VITE_MINIMUM_SUPPORTED_APP_VERSION`** to the new floor **before** or **with** that release (repo Actions variables + both workflow steps above). +3. Remove, redirect, or retire older stable installers and stale **updater** entries from user-facing surfaces (GitHub Release assets, website, CDN, updater feed). Confirm deprecated artifacts are not reachable from default install/update flows. +4. Smoke-test **Gmail connect** on a fresh install from **releases/latest**.