mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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**.
|
||||
Reference in New Issue
Block a user