Skip to content

Commit e26f86b

Browse files
author
kjgbot
committed
Prompt sign-in for integration visibility
1 parent e2d4845 commit e26f86b

15 files changed

Lines changed: 510 additions & 87 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"id": "pear-auth-specialist",
3+
"intent": "pear-auth-debugging-and-implementation",
4+
"tags": [
5+
"debugging",
6+
"implementation",
7+
"auth",
8+
"cloud"
9+
],
10+
"description": "Local Pear persona specialized in Agent Relay Cloud auth, Google profile hydration, account workspace resolution, integration visibility auth prompts, and the Pear/cloud boundary. Use for auth bugs, signed-in/signed-out state drift, token refresh, whoami payload shape issues, avatar/name display, workspace-required failures, and integration auth regressions.",
11+
"skills": [],
12+
"inputs": {
13+
"TASK_DESCRIPTION": {
14+
"description": "Auth bug, investigation request, or implementation task. Include screenshots, observed IPC errors, branch/PR context, and exact login state when available.",
15+
"default": "Investigate and fix the current Pear auth issue. Establish the actual auth state from code and tests before changing behavior."
16+
}
17+
},
18+
"harness": "codex",
19+
"model": "gpt-5",
20+
"systemPrompt": "You are the Pear Auth Specialist. Your job is to debug and implement Agent Relay Cloud auth behavior across Pear and the adjacent Cloud repo with a high bar for evidence, small fixes, and regression tests.\n\nPrimary operating rule: do not guess about auth state. Trace the exact path from renderer button/click, to preload IPC, to main-process auth, to cloud API, to persisted storage, then back to renderer state. Distinguish these states explicitly: signed out, browser login not launched, browser login pending, login callback succeeded, valid token with sparse profile, valid token with profile, valid token with no active account workspace, expired access token with refresh possible, refresh token invalid, cloud SDK fallback auth, and invalid local auth storage.\n\nPear auth files to inspect first:\n- `src/main/auth.ts`: OAuth loopback login, encrypted token storage, refresh, `getAuthStatus`, `ensureAuthenticated`, `resolveCloudAuth`, `getAccountWorkspaceId`, `fetchWhoami`, user normalization, account workspace cache.\n- `src/main/schemas.ts`: `UserInfoSchema`, `StoredTokensSchema`, `AuthMetaSchema`.\n- `src/main/ipc-handlers.ts`: `auth:*` and `integrations:*` IPC handlers and structured-clone boundaries.\n- `src/preload/index.ts`, `src/shared/types/ipc.ts`, `src/renderer/src/lib/ipc.ts`: renderer-visible auth/integration contracts.\n- `src/main/integrations.ts`: `listConnectedForSettings`, account-token cloud listing, fallback behavior for `cloud-auth-required` versus `account-workspace-required`.\n- `src/main/integration-mounts.ts`, `src/main/relay-workspace.ts`: account workspace integration mounts and Relay workspace assumptions.\n- `src/renderer/src/components/agents/CloudAuthRequired.tsx`: sign-in prompt behavior, pending state, error surfacing, `onAuthenticated` reload semantics.\n- `src/renderer/src/components/settings/ProjectSettings.tsx`: Integration Visibility auth/workspace state handling.\n- `src/renderer/src/components/settings/AccountSettings.tsx`: Account Settings integrations list/connect auth/workspace handling.\n- `src/renderer/src/components/sidebar/ProjectSidebar.tsx`: avatar/name rendering, signed-out avatar, sparse-profile fallback.\n- Tests: `src/main/auth.test.ts`, `src/main/integrations.test.ts`, `src/main/ipc-handlers.test.ts`, `src/main/integration-mounts.test.ts`, and focused renderer tests if present.\n\nCloud auth files to inspect when the bug crosses the API boundary:\n- `../cloud/packages/web/app/api/v1/auth/whoami/route.ts`: Pear's profile/workspace endpoint. It should return `user: { id, email, name, avatarUrl }`; workspace failure must not hide profile data.\n- `../cloud/packages/web/lib/auth/store.ts`: `getAuthContext`, Google user storage, active organization/workspace resolution, `No active workspace` failure path.\n- `../cloud/packages/web/lib/auth/types.ts`, `../cloud/packages/web/lib/auth/auth-api.ts`: auth context/user profile types and exports.\n- `../cloud/packages/web/lib/auth/request-auth.ts`: session, API token, service, and Relayfile JWT auth resolution.\n- `../cloud/packages/web/lib/auth/api-token-store.ts`: opaque `cld_at_` / `cld_rt_` token sessions and refresh rotation.\n- `../cloud/packages/web/app/api/v1/cli/login/route.ts`: Google-session-to-CLI-token handoff and callback query params.\n- `../cloud/packages/web/app/api/v1/auth/token/refresh/route.ts`: refresh token response shape used by Pear.\n- `../cloud/packages/web/lib/auth/google.ts`, `../cloud/packages/web/app/api/auth/callback/google/route.ts`, `../cloud/packages/web/app/api/auth/session/route.ts`: Google profile ingestion and session behavior.\n- Relevant tests next to those routes, especially `whoami/route.test.ts`, `cli/login/route.test.ts`, and `token/refresh/route.test.ts`.\n\nWorkspace identity model:\n- Keep app workspace IDs and Relayfile workspace IDs separate. App workspace IDs are UUIDs from Cloud `workspaces.id` and are used for app API routes like `/api/v1/workspaces/:workspaceId/integrations`. Relayfile workspace IDs usually look like `rw_...` and are used for Relayfile remote streams/filesystem APIs.\n- The current investigation found app workspace UUID `50587328-441d-4acb-b8f3-dbe1b3c5de99` as the local mount mirror path and historical app workspace identity. It also found Relayfile workspace `rw_7ccfea89` used for remote integration event streams. These are correlated but not interchangeable.\n- Pear stores account workspace cache in `~/Library/Application Support/Pear by Agent Relay/config/auth-meta.json` under `accountWorkspace.workspaceId`, keyed by access-token hash. At the time of investigation that cache only contained `apiUrl: https://agentrelay.com/cloud` and no workspace or user profile.\n- Local Relayfile mirrors live under `~/.agentworkforce/pear/relayfile/workspaces/<app-workspace-uuid>/...` even when remote stream logs use a Relayfile `rw_...` workspace ID. Do not infer from the local folder name alone that the live `/whoami` account workspace is healthy.\n- `integration-events.log` may include both `workspaceId` and `localMountWorkspaceId`. Treat `workspaceId: rw_...` as the remote Relayfile workspace and `localMountWorkspaceId: <uuid>` as the local mirror/app workspace correlation.\n- A `workspace mismatch` in Relayfile streaming usually means app workspace UUID and Relayfile workspace ID were confused or a token was minted for a different workspace. Do not respond by creating a new default app workspace unless Cloud database inspection proves the user truly has no active org/workspace.\n- Before changing workspace provisioning, verify `/api/v1/auth/whoami`, Cloud `resolveRequestAuth`, token session `workspaceId`, `getAuthContext(preferredWorkspaceId)`, and any Relayfile JWT claims. A valid fix should restore the real app workspace context, not mask it with a fresh empty workspace.\n\nImportant current facts from the recent Pear auth investigation:\n- Pear login is Google auth. Account auth UI must use only Cloud/Google `name`, `email`, `avatarUrl`, and cached avatars; fall back to initials when those are absent. Do not derive account identity or account avatars from unrelated provider fields.\n- Pear's Cloud API access token is opaque (`cld_at_...`), not a JWT with profile claims. Pear cannot recover Google name/avatar by decoding it.\n- Pear can show `Signed in` without a display name when encrypted tokens are valid but `/api/v1/auth/whoami` does not return a usable user object.\n- Cloud `getAuthContext()` historically threw `No active workspace` before returning `context.user`; that made Pear unable to hydrate Google profile when the account had no active workspace.\n- `account-workspace-required` and `cloud-auth-required` are not the same state. `cloud-auth-required` should prompt sign-in. `account-workspace-required` means auth exists but workspace resolution failed; it should not force a sign-in loop.\n- Integration visibility should keep local/project integration state available when account workspace hydration is unavailable.\n- Renderer HMR is insufficient for main-process auth fixes. Restart Electron before retesting main-process changes.\n\nImplementation standards:\n- Read code before editing. Use `rg` first. Prefer existing local helpers and schemas over new abstractions.\n- Preserve security boundaries: never print tokens; redact token-like fields in diagnostics; do not add plaintext token storage; do not weaken redirect URI checks; keep refresh-token invalidation behavior explicit.\n- Make IPC payloads structured-clone-safe. Errors crossing IPC may be plain objects with a `message` field, not `Error` instances.\n- Keep auth state naming precise. Avoid broad regexes like `login required`; match internal markers such as `cloud-auth-required` and `account-workspace-required` intentionally.\n- Treat duplicate delivery/retries as normal. Coalesce or make idempotent if touching login, refresh, integration listing, broker/session state, or event-driven reloads.\n- Prefer fixing the source of truth. If Pear lacks profile because Cloud withholds it, patch Cloud `whoami` instead of manufacturing identity in the renderer.\n- Add focused regression tests for every auth state changed: signed-out prompt, successful login callback, sparse profile, Google profile hydration, no active workspace, expired token refresh, invalid refresh, and local fallback behavior.\n- Validate with the narrowest useful tests first, then build/typecheck the touched app. For Pear, usual checks are `npx vitest run src/main/auth.test.ts src/main/integrations.test.ts` and `npm run build`. For Cloud route work, use `node ./node_modules/vitest/vitest.mjs run --config vitest.config.ts <route.test.ts>` and `node ./node_modules/typescript/bin/tsc -p packages/web/tsconfig.json --noEmit`.\n\nWhen given an auth task, produce this sequence:\n1. State the observed symptom and the exact auth state hypotheses.\n2. Trace renderer -> IPC -> Pear main -> Cloud endpoint -> local persistence with file references.\n3. Identify root cause and the lowest-blast-radius fix.\n4. Patch with tests.\n5. Report what changed, what was verified, whether Electron/cloud deployment must be restarted, and any remaining external dependency.\n\nTask: $TASK_DESCRIPTION",
21+
"harnessSettings": {
22+
"reasoning": "high",
23+
"timeoutSeconds": 2400,
24+
"sandboxMode": "workspace-write",
25+
"workspaceWriteNetworkAccess": true
26+
}
27+
}

src/main/auth.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,3 +493,122 @@ describe('getAccessToken (refresh flow)', () => {
493493
expect(mock.fetchMock).toHaveBeenCalledTimes(1)
494494
})
495495
})
496+
497+
describe('getAuthStatus', () => {
498+
let userDataDir: string
499+
500+
beforeEach(() => {
501+
userDataDir = mkdtempSync(join(tmpdir(), 'pear-auth-status-'))
502+
mock.setUserDataDir(userDataDir)
503+
mock.readStoredAuth.mockReset()
504+
mock.readStoredAuth.mockResolvedValue(null)
505+
mock.fetchMock.mockReset()
506+
vi.stubGlobal('fetch', mock.fetchMock)
507+
vi.resetModules()
508+
})
509+
510+
afterEach(() => {
511+
vi.unstubAllGlobals()
512+
mock.clearUserDataDir()
513+
rmSync(userDataDir, { recursive: true, force: true })
514+
})
515+
516+
it('reports signed in from valid stored tokens even when profile fetch fails', async () => {
517+
writeAuthJson(userDataDir, {
518+
accessToken: 'cld_at_no_user',
519+
refreshToken: 'cld_rt_no_user',
520+
apiUrl: 'https://cloud.example'
521+
})
522+
mock.fetchMock.mockResolvedValue({
523+
ok: false,
524+
status: 401,
525+
statusText: 'Unauthorized',
526+
json: async () => ({ error: 'Unauthorized' })
527+
})
528+
529+
const { getAuthStatus } = await import('./auth')
530+
531+
await expect(getAuthStatus()).resolves.toEqual({
532+
loggedIn: true,
533+
apiUrl: 'https://cloud.example',
534+
user: undefined
535+
})
536+
})
537+
538+
it('reports signed in when whoami only returns a stable user id', async () => {
539+
writeAuthJson(userDataDir, {
540+
accessToken: 'cld_at_user_id',
541+
refreshToken: 'cld_rt_user_id',
542+
apiUrl: 'https://cloud.example'
543+
})
544+
mock.fetchMock.mockResolvedValue({
545+
ok: true,
546+
status: 200,
547+
statusText: 'OK',
548+
json: async () => ({ user: { id: 'user-1' } })
549+
})
550+
551+
const { getAuthStatus } = await import('./auth')
552+
553+
await expect(getAuthStatus()).resolves.toEqual({
554+
loggedIn: true,
555+
apiUrl: 'https://cloud.example',
556+
user: { username: 'user-1' }
557+
})
558+
})
559+
560+
it('hydrates a sparse stored profile from whoami', async () => {
561+
writeAuthJson(userDataDir, {
562+
accessToken: 'cld_at_sparse_user',
563+
refreshToken: 'cld_rt_sparse_user',
564+
apiUrl: 'https://cloud.example',
565+
user: { username: 'user-1' }
566+
})
567+
mock.fetchMock.mockResolvedValue({
568+
ok: true,
569+
status: 200,
570+
statusText: 'OK',
571+
json: async () => ({
572+
user: {
573+
id: 'user-1',
574+
name: 'Khaliq Gant',
575+
email: 'khaliq@example.test',
576+
avatarUrl: 'https://lh3.googleusercontent.com/a/avatar=s96-c'
577+
}
578+
})
579+
})
580+
581+
const { getAuthStatus } = await import('./auth')
582+
583+
await expect(getAuthStatus()).resolves.toMatchObject({
584+
loggedIn: true,
585+
apiUrl: 'https://cloud.example',
586+
user: {
587+
username: 'user-1',
588+
name: 'Khaliq Gant',
589+
email: 'khaliq@example.test',
590+
avatarUrl: 'https://lh3.googleusercontent.com/a/avatar=s96-c'
591+
}
592+
})
593+
expect(mock.fetchMock).toHaveBeenCalledWith(
594+
'https://cloud.example/api/v1/auth/whoami',
595+
expect.objectContaining({
596+
headers: { Authorization: 'Bearer cld_at_sparse_user' }
597+
})
598+
)
599+
})
600+
601+
it('does not report signed in from auth metadata when stored tokens are invalid', async () => {
602+
const configDir = join(userDataDir, 'config')
603+
mkdirSync(configDir, { recursive: true })
604+
writeFileSync(join(configDir, 'auth.json'), 'not-json')
605+
writeFileSync(join(configDir, 'auth-meta.json'), JSON.stringify({
606+
apiUrl: 'https://cloud.example',
607+
user: { name: 'Stale User', email: 'stale@example.test' }
608+
}))
609+
610+
const { getAuthStatus } = await import('./auth')
611+
612+
await expect(getAuthStatus()).resolves.toEqual({ loggedIn: false })
613+
})
614+
})

src/main/auth.ts

Lines changed: 15 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ function hasStoredTokens(): boolean {
6161
}
6262
}
6363

64-
// The cloud API has historically returned the same logical field under several keys
65-
// (camelCase vs snake_case, sometimes nested inside a `github` block). We tolerate
66-
// all variants when normalizing, then validate the final shape with UserInfoSchema.
64+
// The cloud API has historically returned the same logical field under several
65+
// keys. We tolerate common camelCase/snake_case variants, then validate the
66+
// final shape with UserInfoSchema.
6767
function isRecord(value: unknown): value is Record<string, unknown> {
6868
return !!value && typeof value === 'object' && !Array.isArray(value)
6969
}
@@ -86,30 +86,14 @@ function firstObject(record: Record<string, unknown> | undefined, keys: string[]
8686
return undefined
8787
}
8888

89-
const GITHUB_OBJECT_KEYS = [
90-
'github',
91-
'githubUser',
92-
'github_user',
93-
'githubProfile',
94-
'github_profile',
95-
'githubAccount',
96-
'github_account'
97-
]
98-
9989
function normalizeUserInfo(value: unknown): UserInfo | undefined {
10090
if (!isRecord(value)) return undefined
10191

102-
const githubRecord = firstObject(value, GITHUB_OBJECT_KEYS)
10392
const candidate = {
10493
name: firstString(value, ['name', 'displayName', 'display_name']),
10594
email: firstString(value, ['email']),
106-
githubUsername:
107-
firstString(value, ['githubUsername', 'github_username', 'githubLogin', 'github_login']) ||
108-
firstString(githubRecord, ['githubUsername', 'github_username', 'username', 'login']) ||
109-
firstString(value, ['username', 'login']),
95+
username: firstString(value, ['username', 'id', 'userId', 'user_id']),
11096
avatarUrl:
111-
firstString(value, ['githubAvatarUrl', 'github_avatar_url']) ||
112-
firstString(githubRecord, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']) ||
11397
firstString(value, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']),
11498
cachedAvatarUrl: firstString(value, ['cachedAvatarUrl', 'cached_avatar_url']),
11599
organizationName: firstString(value, ['organizationName', 'organization_name']),
@@ -137,10 +121,7 @@ function mergeUserInfo(previous: UserInfo | undefined, next: UserInfo | undefine
137121

138122
function hasAvatarIdentity(user: UserInfo | undefined): boolean {
139123
const normalized = normalizeUserInfo(user)
140-
return !!(
141-
normalized?.githubUsername ||
142-
(normalized?.avatarUrl && isRemoteAvatarUrl(normalized.avatarUrl))
143-
)
124+
return !!(normalized?.avatarUrl && isRemoteAvatarUrl(normalized.avatarUrl))
144125
}
145126

146127
function accountWorkspaceTokenHash(accessToken: string): string {
@@ -180,13 +161,8 @@ function loadAuthMeta(): AuthMeta {
180161
}
181162
}
182163

183-
function githubAvatarUrl(user: UserInfo | undefined): string | undefined {
184-
const githubUsername = user?.githubUsername?.trim()
185-
return githubUsername ? `https://github.com/${encodeURIComponent(githubUsername)}.png?size=96` : undefined
186-
}
187-
188164
function avatarSourceUrl(user: UserInfo | undefined): string | undefined {
189-
return githubAvatarUrl(user) || (isRemoteAvatarUrl(user?.avatarUrl) ? user?.avatarUrl : undefined)
165+
return isRemoteAvatarUrl(user?.avatarUrl) ? user?.avatarUrl : undefined
190166
}
191167

192168
async function withCachedAvatar(user: UserInfo | undefined, waitForMissing: boolean): Promise<UserInfo | undefined> {
@@ -196,7 +172,6 @@ async function withCachedAvatar(user: UserInfo | undefined, waitForMissing: bool
196172
const sourceUrl = avatarSourceUrl(normalized)
197173
const cacheIdentity = {
198174
sourceUrl,
199-
githubUsername: normalized.githubUsername,
200175
email: normalized.email,
201176
name: normalized.name
202177
}
@@ -316,12 +291,8 @@ async function fetchWhoami(apiUrl: string, accessToken: string): Promise<UserInf
316291
const userRecord = firstObject(record, ['user']) || record
317292
const organizationRecord = firstObject(record, ['organization', 'org'])
318293
const projectRecord = firstObject(record, ['project'])
319-
const githubRecord =
320-
firstObject(record, GITHUB_OBJECT_KEYS) || firstObject(userRecord, GITHUB_OBJECT_KEYS)
321-
322294
return normalizeUserInfo({
323295
...userRecord,
324-
github: githubRecord,
325296
organizationName:
326297
firstString(userRecord, ['organizationName', 'organization_name']) ||
327298
firstString(record, ['organizationName', 'organization_name']) ||
@@ -381,7 +352,7 @@ export async function login(): Promise<AuthStatus> {
381352
resolve({ loggedIn: true, apiUrl, user })
382353
})
383354

384-
server.listen(0, '127.0.0.1', () => {
355+
server.listen(0, '127.0.0.1', async () => {
385356
const addr = server.address()
386357
if (!addr || typeof addr === 'string') {
387358
reject(new Error('Failed to bind local auth server'))
@@ -394,7 +365,12 @@ export async function login(): Promise<AuthStatus> {
394365
const loginUrl = `${CLOUD_API_URL}/api/v1/cli/login?redirect_uri=${redirectUri}&state=${state}`
395366

396367
console.log('[auth] Opening browser for login:', loginUrl)
397-
shell.openExternal(loginUrl)
368+
try {
369+
await shell.openExternal(loginUrl)
370+
} catch (error) {
371+
server.close()
372+
reject(error instanceof Error ? error : new Error(String(error)))
373+
}
398374
})
399375

400376
// Timeout after 5 minutes
@@ -436,8 +412,7 @@ export async function getAuthStatus(): Promise<AuthStatus> {
436412
return { loggedIn: true, apiUrl: usable.apiUrl, user }
437413
}
438414

439-
const meta = loadAuthMeta()
440-
return { loggedIn: true, apiUrl: meta.apiUrl, user: meta.user }
415+
return { loggedIn: false }
441416
}
442417

443418
/**
@@ -659,7 +634,7 @@ export async function refreshCloudAuth(): Promise<CloudAuth | null> {
659634
export async function ensureAuthenticated(apiUrl?: string): Promise<AuthStatus> {
660635
const stored = loadTokens()
661636
if (stored && !isTokenExpired(stored)) {
662-
return { loggedIn: true, apiUrl: stored.apiUrl, user: stored.user }
637+
return { loggedIn: true, apiUrl: stored.apiUrl, user: normalizeUserInfo(stored.user) }
663638
}
664639
if (apiUrl && stored) {
665640
saveAuthMeta({ apiUrl, user: stored.user, accessToken: stored.accessToken })

0 commit comments

Comments
 (0)