Fix/365 enforce latest oauth gate from 351 (#421)

* 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
This commit is contained in:
Mega Mind
2026-04-08 04:26:19 +05:30
committed by GitHub
parent eded18a703
commit 5551e1e0aa
9 changed files with 245 additions and 2 deletions
+4
View File
@@ -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
+6
View File
@@ -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 }}
+5
View File
@@ -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
+19
View File
@@ -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';
+55 -2
View File
@@ -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<ReturnType<typeof evaluateOAuthAppVersionGate>>;
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);
}
};
+64
View File
@@ -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<OAuthAppVersionGateResult> {
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');
}
}
+26
View File
@@ -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);
});
});
+32
View File
@@ -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;
};
+34
View File
@@ -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**.