Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ dist/
*.env
.DS_Store
/.agent-relay
*.tsbuildinfo
3 changes: 2 additions & 1 deletion electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export default defineConfig({
renderer: {
resolve: {
alias: {
'@': resolve('src/renderer/src')
'@': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
}
},
plugins: [react(), tailwindcss()]
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"react-dom": "^19.0.0",
"shiki": "^4.0.2",
"unidiff": "^1.0.4",
"zod": "^3.25.76",
"zustand": "^5.0.0"
},
"devDependencies": {
Expand Down
129 changes: 62 additions & 67 deletions src/main/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,16 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
import { join } from 'path'
import { URL } from 'url'
import { cacheAvatarFromUrl, cachedAvatarUrl, isRemoteAvatarUrl } from './avatar-cache'
import {
AuthMetaSchema,
StoredTokensSchema,
UserInfoSchema,
type StoredTokens,
type UserInfo
} from './schemas'

const CLOUD_API_URL = process.env.RELAY_CLOUD_URL || 'https://agentrelay.dev/cloud'

interface StoredTokens {
accessToken: string
refreshToken: string
apiUrl: string
expiresAt?: string
user?: UserInfo
}

interface UserInfo {
name?: string
email?: string
githubUsername?: string
avatarUrl?: string
cachedAvatarUrl?: string
organizationName?: string
projectName?: string
}

interface AuthStatus {
loggedIn: boolean
apiUrl?: string
Expand Down Expand Up @@ -53,46 +42,9 @@ function hasStoredTokens(): boolean {
}
}

function normalizeUserInfo(value: unknown): UserInfo | undefined {
if (!isRecord(value)) return undefined

const record = value
const githubRecord = firstObject(record, [
'github',
'githubUser',
'github_user',
'githubProfile',
'github_profile',
'githubAccount',
'github_account'
])
const user: UserInfo = {}
const githubUsername =
firstString(record, ['githubUsername', 'github_username', 'githubLogin', 'github_login']) ||
firstString(githubRecord, ['githubUsername', 'github_username', 'username', 'login']) ||
firstString(record, ['username', 'login'])
const avatarUrl =
firstString(record, ['githubAvatarUrl', 'github_avatar_url']) ||
firstString(githubRecord, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']) ||
firstString(record, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image'])
const cachedAvatarUrl = firstString(record, ['cachedAvatarUrl', 'cached_avatar_url'])

const name = firstString(record, ['name', 'displayName', 'display_name'])
const email = firstString(record, ['email'])
const organizationName = firstString(record, ['organizationName', 'organization_name'])
const projectName = firstString(record, ['projectName', 'project_name'])

if (name) user.name = name
if (email) user.email = email
if (githubUsername) user.githubUsername = githubUsername
if (avatarUrl) user.avatarUrl = avatarUrl
if (cachedAvatarUrl) user.cachedAvatarUrl = cachedAvatarUrl
if (organizationName) user.organizationName = organizationName
if (projectName) user.projectName = projectName

return Object.keys(user).length > 0 ? user : undefined
}

// The cloud API has historically returned the same logical field under several keys
// (camelCase vs snake_case, sometimes nested inside a `github` block). We tolerate
// all variants when normalizing, then validate the final shape with UserInfoSchema.
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
Expand All @@ -115,6 +67,48 @@ function firstObject(record: Record<string, unknown> | undefined, keys: string[]
return undefined
}

const GITHUB_OBJECT_KEYS = [
'github',
'githubUser',
'github_user',
'githubProfile',
'github_profile',
'githubAccount',
'github_account'
]

function normalizeUserInfo(value: unknown): UserInfo | undefined {
if (!isRecord(value)) return undefined

const githubRecord = firstObject(value, GITHUB_OBJECT_KEYS)
const candidate = {
name: firstString(value, ['name', 'displayName', 'display_name']),
email: firstString(value, ['email']),
githubUsername:
firstString(value, ['githubUsername', 'github_username', 'githubLogin', 'github_login']) ||
firstString(githubRecord, ['githubUsername', 'github_username', 'username', 'login']) ||
firstString(value, ['username', 'login']),
avatarUrl:
firstString(value, ['githubAvatarUrl', 'github_avatar_url']) ||
firstString(githubRecord, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']) ||
firstString(value, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']),
cachedAvatarUrl: firstString(value, ['cachedAvatarUrl', 'cached_avatar_url']),
organizationName: firstString(value, ['organizationName', 'organization_name']),
projectName: firstString(value, ['projectName', 'project_name'])
}

const parsed = UserInfoSchema.parse(candidate)
// Zod preserves keys with `undefined` values from the input, so we strip
// them before returning. Without this, an empty whoami payload would shadow
// previously cached fields during `mergeUserInfo` (e.g. clearing the cached
// avatar URL on every refresh).
const user: UserInfo = {}
for (const [key, value] of Object.entries(parsed) as Array<[keyof UserInfo, string | undefined]>) {
if (value !== undefined) user[key] = value
}
return Object.keys(user).length > 0 ? user : undefined
}

function mergeUserInfo(previous: UserInfo | undefined, next: UserInfo | undefined): UserInfo | undefined {
const normalizedPrevious = normalizeUserInfo(previous)
const normalizedNext = normalizeUserInfo(next)
Expand All @@ -140,10 +134,11 @@ function saveAuthMeta(tokens: Pick<StoredTokens, 'apiUrl' | 'user'>): void {

function loadAuthMeta(): Pick<AuthStatus, 'apiUrl' | 'user'> {
try {
const record = JSON.parse(readFileSync(getAuthMetaPath(), 'utf8')) as Record<string, unknown>
const parsed = AuthMetaSchema.safeParse(JSON.parse(readFileSync(getAuthMetaPath(), 'utf8')))
if (!parsed.success) return { apiUrl: CLOUD_API_URL }
return {
apiUrl: typeof record.apiUrl === 'string' ? record.apiUrl : CLOUD_API_URL,
user: normalizeUserInfo(record.user)
apiUrl: parsed.data.apiUrl?.trim() || CLOUD_API_URL,
user: parsed.data.user
}
} catch {
return { apiUrl: CLOUD_API_URL }
Expand Down Expand Up @@ -197,9 +192,10 @@ function loadTokens(): StoredTokens | null {
try {
const raw = readFileSync(getAuthPath())
const decrypted = safeStorage.decryptString(raw)
const tokens = JSON.parse(decrypted) as StoredTokens
saveAuthMeta(tokens)
return tokens
const parsed = StoredTokensSchema.safeParse(JSON.parse(decrypted))
if (!parsed.success) return null
saveAuthMeta(parsed.data)
return parsed.data
} catch {
return null
}
Expand All @@ -224,14 +220,13 @@ async function fetchWhoami(apiUrl: string, accessToken: string): Promise<UserInf
signal: controller.signal
})
if (!res.ok) return undefined
const data = await res.json() as unknown
const data: unknown = await res.json()
const record = isRecord(data) ? data : {}
const userRecord = firstObject(record, ['user']) || record
const organizationRecord = firstObject(record, ['organization', 'org'])
const projectRecord = firstObject(record, ['project'])
const githubRecord =
firstObject(record, ['github', 'githubUser', 'github_user', 'githubProfile', 'github_profile', 'githubAccount', 'github_account']) ||
firstObject(userRecord, ['github', 'githubUser', 'github_user', 'githubProfile', 'github_profile', 'githubAccount', 'github_account'])
firstObject(record, GITHUB_OBJECT_KEYS) || firstObject(userRecord, GITHUB_OBJECT_KEYS)

return normalizeUserInfo({
...userRecord,
Expand Down
26 changes: 7 additions & 19 deletions src/main/avatar-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { readFile, rename, writeFile } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import {
AvatarCacheManifestSchema,
type AvatarCacheEntry,
type AvatarCacheManifest
} from './schemas'

const AVATAR_PROTOCOL = 'pear-avatar'
const AVATAR_HOST = 'avatar'
Expand All @@ -12,20 +17,6 @@ const MANIFEST_PATH = join(CACHE_DIR, 'avatars.json')
const MAX_AVATAR_BYTES = 1_500_000
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000

type AvatarCacheEntry = {
key: string
sourceUrl: string
fileName: string
contentType: string
byteLength: number
updatedAt: string
}

type AvatarCacheManifest = {
version: 1
avatars: Record<string, AvatarCacheEntry>
}

type AvatarIdentity = {
sourceUrl?: string
githubUsername?: string
Expand All @@ -41,14 +32,11 @@ function ensureCacheDir(): void {

function loadManifest(): AvatarCacheManifest {
try {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as AvatarCacheManifest
if (manifest.version === 1 && manifest.avatars && typeof manifest.avatars === 'object') {
return manifest
}
const parsed = AvatarCacheManifestSchema.safeParse(JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')))
if (parsed.success) return parsed.data
} catch {
// Cache metadata is best-effort; corrupt or missing metadata just starts fresh.
}

return { version: 1, avatars: {} }
}

Expand Down
Loading