From cb78e9c9a19067eb6015fd8227249c2798d1de89 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 5 Jun 2026 15:11:19 +0200 Subject: [PATCH] Retry Relayfile setup after cloud auth refresh --- src/main/auth.ts | 23 +++++++++---- src/main/integration-event-bridge.ts | 48 ++++++++++++++++++++-------- src/main/integration-mounts.ts | 43 +++++++++++++++++++------ 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/src/main/auth.ts b/src/main/auth.ts index 68506358..ced74020 100644 --- a/src/main/auth.ts +++ b/src/main/auth.ts @@ -564,6 +564,15 @@ export interface CloudAuth { accountKey: string } +function cloudAuthFromStored(tokens: StoredTokens): CloudAuth { + const apiUrl = normalizeCloudApiUrl(tokens.apiUrl) + return { + accessToken: tokens.accessToken, + apiUrl, + accountKey: deriveCloudAuthAccountKey(apiUrl, tokens.accessToken, tokens.user) + } +} + function normalizeCloudApiUrl(url: string | undefined): string { return (url || getApiUrl()).trim().replace(/\/+$/, '') } @@ -623,12 +632,7 @@ export async function resolveCloudAuth(): Promise { ? (await refreshStoredTokens(pearAuth)) ?? (loadTokens() ?? null) : pearAuth if (usable) { - const apiUrl = normalizeCloudApiUrl(usable.apiUrl) - return { - accessToken: usable.accessToken, - apiUrl, - accountKey: deriveCloudAuthAccountKey(apiUrl, usable.accessToken, usable.user) - } + return cloudAuthFromStored(usable) } } @@ -645,6 +649,13 @@ export async function resolveCloudAuth(): Promise { return null } +export async function refreshCloudAuth(): Promise { + const pearAuth = loadTokens() + if (!pearAuth) return null + const refreshed = await refreshStoredTokens(pearAuth) + return refreshed ? cloudAuthFromStored(refreshed) : null +} + export async function ensureAuthenticated(apiUrl?: string): Promise { const stored = loadTokens() if (stored && !isTokenExpired(stored)) { diff --git a/src/main/integration-event-bridge.ts b/src/main/integration-event-bridge.ts index 0f64a99d..f6e1f4d8 100644 --- a/src/main/integration-event-bridge.ts +++ b/src/main/integration-event-bridge.ts @@ -101,6 +101,15 @@ function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function isUnauthorizedError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const status = (error as { httpStatus?: unknown; status?: unknown }).httpStatus ?? + (error as { httpStatus?: unknown; status?: unknown }).status + if (status === 401 || status === 403) return true + const message = (error as { message?: unknown }).message + return typeof message === 'string' && /\b(401|403|unauthor|forbidden)\b/i.test(message) +} + function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } @@ -933,8 +942,8 @@ export class IntegrationEventBridge { private async getWorkspaceHandle(): Promise { if (this.deps.getWorkspaceHandle) return this.deps.getWorkspaceHandle() - const { accountWorkspaceReadyRetryOptions, getAccountWorkspaceId, resolveCloudAuth } = await import('./auth.ts') - const auth = await resolveCloudAuth() + const { accountWorkspaceReadyRetryOptions, getAccountWorkspaceId, refreshCloudAuth, resolveCloudAuth } = await import('./auth.ts') + let auth = await resolveCloudAuth() if (!auth) { accountIntegrationEventHandle = null throw new Error('cloud-auth-required') @@ -950,18 +959,31 @@ export class IntegrationEventBridge { return accountIntegrationEventHandle.handle } - const tokenProvider = async (): Promise => { - const fresh = await resolveCloudAuth() - return fresh?.accessToken ?? auth.accessToken + const joinWorkspace = async () => { + const tokenProvider = async (): Promise => { + const fresh = await resolveCloudAuth() + return fresh?.accessToken ?? auth?.accessToken + } + const setup = new RelayfileSetup({ + cloudApiUrl: auth.apiUrl, + accessToken: tokenProvider + }) + return setup.joinWorkspace(accountWorkspaceId, { + agentName: INTEGRATION_EVENT_AGENT_NAME, + scopes: INTEGRATION_EVENT_SCOPES + }) + } + + let joined: Awaited> + try { + joined = await joinWorkspace() + } catch (error) { + if (!isUnauthorizedError(error)) throw error + const refreshed = await refreshCloudAuth() + if (!refreshed) throw error + auth = refreshed + joined = await joinWorkspace() } - const setup = new RelayfileSetup({ - cloudApiUrl: auth.apiUrl, - accessToken: tokenProvider - }) - const joined = await setup.joinWorkspace(accountWorkspaceId, { - agentName: INTEGRATION_EVENT_AGENT_NAME, - scopes: INTEGRATION_EVENT_SCOPES - }) const relayWorkspaceId = joined.workspaceId const workspaceTokenProvider = async (): Promise => { await joined.refreshToken() diff --git a/src/main/integration-mounts.ts b/src/main/integration-mounts.ts index 88d7d184..03f0d032 100644 --- a/src/main/integration-mounts.ts +++ b/src/main/integration-mounts.ts @@ -5,7 +5,7 @@ import { RelayfileSetup, type MountedWorkspaceHandle } from '@relayfile/sdk' -import { accountWorkspaceReadyRetryOptions, getAccountWorkspaceId, resolveCloudAuth } from './auth' +import { accountWorkspaceReadyRetryOptions, getAccountWorkspaceId, refreshCloudAuth, resolveCloudAuth } from './auth' import { createPearMountLauncher } from './relayfile-mount-launcher' const MOUNT_READY_TIMEOUT_MS = 60_000 @@ -29,6 +29,15 @@ function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function isUnauthorizedError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const status = (error as { httpStatus?: unknown; status?: unknown }).httpStatus ?? + (error as { httpStatus?: unknown; status?: unknown }).status + if (status === 401 || status === 403) return true + const message = (error as { message?: unknown }).message + return typeof message === 'string' && /\b(401|403|unauthor|forbidden)\b/i.test(message) +} + function sanitizePathSegment(value: string): string { return value .trim() @@ -238,13 +247,8 @@ export class IntegrationMountManager { await ensureProtectedDirectory(integrationMountWorkspaceRoot(workspaceId)) await ensureProtectedDirectory(mountRoot) - const setup = new RelayfileSetup({ - cloudApiUrl: auth.apiUrl, - accessToken: async () => { - const fresh = await resolveCloudAuth() - return fresh?.accessToken ?? auth.accessToken - } - }) + let activeAuth = auth + let setup = this.createSetup(activeAuth) const mountSpecs = this.mountSpecsFor(mountPaths, mountRoot) const expectedRemotePaths = mountSpecs.map((spec) => spec.remotePath) @@ -269,7 +273,7 @@ export class IntegrationMountManager { await ensureProtectedDirectory(spec.localDir) try { - const handle = await setup.mountWorkspace({ + const startMount = async (): Promise => setup.mountWorkspace({ workspaceId, localDir: spec.localDir, remotePath: spec.remotePath, @@ -287,6 +291,17 @@ export class IntegrationMountManager { }), readyTimeoutMs: MOUNT_READY_TIMEOUT_MS }) + let handle: MountedWorkspaceHandle + try { + handle = await startMount() + } catch (error) { + if (!isUnauthorizedError(error)) throw error + const refreshed = await refreshCloudAuth() + if (!refreshed) throw error + activeAuth = refreshed + setup = this.createSetup(activeAuth) + handle = await startMount() + } this.handles.set(spec.remotePath, handle) this.scheduleRefresh(spec.remotePath, handle) } catch (error) { @@ -300,6 +315,16 @@ export class IntegrationMountManager { await this.removeLegacyIntegrationMountRoot(mountRoot) } + private createSetup(auth: { apiUrl: string; accessToken: string }): RelayfileSetup { + return new RelayfileSetup({ + cloudApiUrl: auth.apiUrl, + accessToken: async () => { + const fresh = await resolveCloudAuth() + return fresh?.accessToken ?? auth.accessToken + } + }) + } + private mountSpecsFor(mountPaths: string[], mountRoot: string): IntegrationMountSpec[] { const specs: IntegrationMountSpec[] = mountPaths.map((mountPath) => { const providerSegment = remotePathSegments(mountPath)[0] || 'integration'