Skip to content

Commit bb46f34

Browse files
authored
Merge pull request #137 from AgentWorkforce/fix/auth-whoami-timeout-prod-default
fix(auth): use production cloud and tolerate whoami latency
2 parents 89eec3a + 400a87e commit bb46f34

2 files changed

Lines changed: 106 additions & 12 deletions

File tree

src/main/auth.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ describe('getAccountWorkspaceId', () => {
127127
expect(mock.fetchMock).not.toHaveBeenCalled()
128128
})
129129

130+
it('uses the production cloud URL by default', async () => {
131+
const { getApiUrl } = await import('./auth')
132+
expect(getApiUrl()).toBe('https://agentrelay.com/cloud')
133+
})
134+
130135
it('returns the workspace id from currentWorkspace.id and persists the cache', async () => {
131136
writeAuthJson(userDataDir, {
132137
accessToken: 'cld_at_abc',
@@ -335,6 +340,61 @@ describe('getAccountWorkspaceId', () => {
335340
expect(mock.fetchMock).toHaveBeenCalledTimes(2)
336341
})
337342

343+
it('retries when whoami temporarily times out', async () => {
344+
writeAuthJson(userDataDir, {
345+
accessToken: 'cld_at_timeout_retry',
346+
refreshToken: 'cld_rt_timeout_retry',
347+
apiUrl: 'https://cloud.example'
348+
})
349+
mock.fetchMock
350+
.mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' }))
351+
.mockResolvedValueOnce({
352+
ok: true,
353+
status: 200,
354+
statusText: 'OK',
355+
json: async () => ({ currentWorkspace: { id: 'ws-after-timeout' } })
356+
})
357+
358+
const { getAccountWorkspaceId } = await import('./auth')
359+
await expect(getAccountWorkspaceId({ retryAttempts: 2, retryDelayMs: 0 })).resolves.toBe('ws-after-timeout')
360+
expect(mock.fetchMock).toHaveBeenCalledTimes(2)
361+
})
362+
363+
it('canonicalizes legacy agentrelay.dev auth records to production', async () => {
364+
writeAuthJson(userDataDir, {
365+
accessToken: 'cld_at_legacy_dev',
366+
refreshToken: 'cld_rt_legacy_dev',
367+
apiUrl: 'https://agentrelay.dev/cloud'
368+
})
369+
mock.fetchMock.mockResolvedValue({
370+
ok: true,
371+
status: 200,
372+
statusText: 'OK',
373+
json: async () => ({ currentWorkspace: { id: 'ws-prod' } })
374+
})
375+
376+
const { getAccountWorkspaceId } = await import('./auth')
377+
await expect(getAccountWorkspaceId()).resolves.toBe('ws-prod')
378+
expect(mock.fetchMock).toHaveBeenCalledWith(
379+
'https://agentrelay.com/cloud/api/v1/auth/whoami',
380+
expect.any(Object)
381+
)
382+
})
383+
384+
it('canonicalizes legacy agentrelay.dev metadata used by getApiUrl callers', async () => {
385+
writeAuthJson(userDataDir, {
386+
accessToken: 'cld_at_legacy_meta',
387+
refreshToken: 'cld_rt_legacy_meta',
388+
apiUrl: 'https://agentrelay.dev/cloud'
389+
})
390+
391+
const { getAccessToken, getApiUrl } = await import('./auth')
392+
await expect(getAccessToken()).resolves.toBe('cld_at_legacy_meta')
393+
394+
expect(getApiUrl()).toBe('https://agentrelay.com/cloud')
395+
expect(readMeta(userDataDir)?.apiUrl).toBe('https://agentrelay.com/cloud')
396+
})
397+
338398
it('throws cloud-auth-required when whoami rejects the access token', async () => {
339399
writeAuthJson(userDataDir, {
340400
accessToken: 'cld_at_401',
@@ -622,6 +682,32 @@ describe('getAuthStatus', () => {
622682
})
623683
})
624684

685+
it('canonicalizes legacy agentrelay.dev stored tokens before refreshing auth status', async () => {
686+
writeAuthJson(userDataDir, {
687+
accessToken: 'cld_at_legacy_status',
688+
refreshToken: 'cld_rt_legacy_status',
689+
apiUrl: 'https://agentrelay.dev/cloud'
690+
})
691+
mock.fetchMock.mockResolvedValue({
692+
ok: true,
693+
status: 200,
694+
statusText: 'OK',
695+
json: async () => ({ user: { id: 'user-legacy' } })
696+
})
697+
698+
const { getAuthStatus } = await import('./auth')
699+
700+
await expect(getAuthStatus()).resolves.toEqual({
701+
loggedIn: true,
702+
apiUrl: 'https://agentrelay.com/cloud',
703+
user: { username: 'user-legacy' }
704+
})
705+
expect(mock.fetchMock).toHaveBeenCalledWith(
706+
'https://agentrelay.com/cloud/api/v1/auth/whoami',
707+
expect.any(Object)
708+
)
709+
})
710+
625711
it('hydrates a sparse stored profile from whoami', async () => {
626712
writeAuthJson(userDataDir, {
627713
accessToken: 'cld_at_sparse_user',

src/main/auth.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
type UserInfo
1515
} from './schemas'
1616

17-
const CLOUD_API_URL = process.env.RELAY_CLOUD_URL || 'https://agentrelay.dev/cloud'
17+
const CLOUD_API_URL = process.env.RELAY_CLOUD_URL || 'https://agentrelay.com/cloud'
18+
const LEGACY_CLOUD_API_URL = 'https://agentrelay.dev/cloud'
1819
const TOKEN_EXPIRY_BUFFER_MS = 60_000
20+
const WHOAMI_REQUEST_TIMEOUT_MS = 10_000
1921
const ACCOUNT_WORKSPACE_RETRY_ATTEMPTS = 8
2022
const ACCOUNT_WORKSPACE_RETRY_DELAY_MS = 500
2123
const warnedWhoamiWorkspaceFailures = new Set<string>()
@@ -145,8 +147,9 @@ function delay(ms: number): Promise<void> {
145147

146148
function saveAuthMeta(tokens: Pick<StoredTokens, 'apiUrl' | 'user'> & Partial<Pick<StoredTokens, 'accessToken'>>): void {
147149
const previous = loadAuthMeta()
150+
const apiUrl = normalizeCloudApiUrl(tokens.apiUrl)
148151
const accountKey = tokens.accessToken
149-
? deriveCloudAuthAccountKey(tokens.apiUrl, tokens.accessToken, tokens.user)
152+
? deriveCloudAuthAccountKey(apiUrl, tokens.accessToken, tokens.user)
150153
: undefined
151154
const tokenHash = tokens.accessToken ? accountWorkspaceTokenHash(tokens.accessToken) : undefined
152155
const accountWorkspace =
@@ -161,7 +164,7 @@ function saveAuthMeta(tokens: Pick<StoredTokens, 'apiUrl' | 'user'> & Partial<Pi
161164
}
162165
: undefined
163166
const meta = {
164-
apiUrl: tokens.apiUrl,
167+
apiUrl,
165168
user: tokens.user,
166169
...(accountWorkspace ? { accountWorkspace } : {})
167170
}
@@ -172,8 +175,9 @@ function loadAuthMeta(): AuthMeta {
172175
try {
173176
const parsed = AuthMetaSchema.safeParse(JSON.parse(readFileSync(getAuthMetaPath(), 'utf8')))
174177
if (!parsed.success) return { apiUrl: CLOUD_API_URL }
178+
const apiUrl = (parsed.data.apiUrl?.trim() || CLOUD_API_URL).replace(/\/+$/, '')
175179
return {
176-
apiUrl: parsed.data.apiUrl?.trim() || CLOUD_API_URL,
180+
apiUrl: apiUrl === LEGACY_CLOUD_API_URL ? CLOUD_API_URL : apiUrl,
177181
user: parsed.data.user,
178182
accountWorkspace: parsed.data.accountWorkspace
179183
}
@@ -225,8 +229,9 @@ function loadTokens(): StoredTokens | null {
225229
const decrypted = safeStorage.decryptString(raw)
226230
const parsed = StoredTokensSchema.safeParse(JSON.parse(decrypted))
227231
if (!parsed.success) return null
228-
saveAuthMeta(parsed.data)
229-
return parsed.data
232+
const tokens = { ...parsed.data, apiUrl: normalizeCloudApiUrl(parsed.data.apiUrl) }
233+
saveAuthMeta(tokens)
234+
return tokens
230235
} catch {
231236
return null
232237
}
@@ -277,7 +282,7 @@ function warnWhoamiWorkspaceFailure(failureClass: string): void {
277282

278283
async function fetchWhoamiPayload(apiUrl: string, accessToken: string): Promise<WhoamiPayloadResult> {
279284
const controller = new AbortController()
280-
const timeout = setTimeout(() => controller.abort(), 2500)
285+
const timeout = setTimeout(() => controller.abort(), WHOAMI_REQUEST_TIMEOUT_MS)
281286

282287
try {
283288
const res = await fetch(`${apiUrl}/api/v1/auth/whoami`, {
@@ -320,7 +325,7 @@ function accountWorkspaceIdFromWhoami(value: unknown): string | undefined {
320325
function saveAccountWorkspaceCache(auth: CloudAuth, workspaceId: string): void {
321326
const previous = loadAuthMeta()
322327
const meta = {
323-
apiUrl: auth.apiUrl || previous.apiUrl?.trim() || CLOUD_API_URL,
328+
apiUrl: normalizeCloudApiUrl(auth.apiUrl || previous.apiUrl),
324329
user: previous.user,
325330
accountWorkspace: {
326331
accountKey: auth.accountKey,
@@ -577,7 +582,7 @@ async function performTokenRefresh(stored: StoredTokens): Promise<StoredTokens |
577582

578583
export function getApiUrl(): string {
579584
if (hasStoredTokens()) {
580-
return loadAuthMeta().apiUrl || CLOUD_API_URL
585+
return normalizeCloudApiUrl(loadAuthMeta().apiUrl)
581586
}
582587
return CLOUD_API_URL
583588
}
@@ -598,7 +603,9 @@ function cloudAuthFromStored(tokens: StoredTokens): CloudAuth {
598603
}
599604

600605
function normalizeCloudApiUrl(url: string | undefined): string {
601-
return (url || getApiUrl()).trim().replace(/\/+$/, '')
606+
const normalized = (url || CLOUD_API_URL).trim().replace(/\/+$/, '')
607+
if (normalized === LEGACY_CLOUD_API_URL) return CLOUD_API_URL
608+
return normalized
602609
}
603610

604611
function readJwtPayload(token: string): Record<string, unknown> | null {
@@ -696,8 +703,9 @@ export async function getAccountWorkspaceId(options: AccountWorkspaceIdOptions =
696703
if (!auth) throw new Error('cloud-auth-required')
697704

698705
const cached = loadAuthMeta().accountWorkspace
699-
if (accountWorkspaceCacheMatches(cached, auth)) {
700-
return cached.workspaceId.trim()
706+
const cachedWorkspaceId = cached?.workspaceId.trim()
707+
if (cachedWorkspaceId && accountWorkspaceCacheMatches(cached, auth)) {
708+
return cachedWorkspaceId
701709
}
702710

703711
const retryAttempts = Math.max(1, Math.floor(options.retryAttempts ?? 1))

0 commit comments

Comments
 (0)