Skip to content

Commit 89eec3a

Browse files
authored
Merge pull request #135 from AgentWorkforce/fix/integration-visibility-auth-prompt
Prompt sign-in for integration visibility
2 parents e2d4845 + ee3748e commit 89eec3a

21 files changed

Lines changed: 1161 additions & 154 deletions
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: 188 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ function tokenHash(accessToken: string): string {
9191
return createHash('sha256').update(accessToken).digest('hex')
9292
}
9393

94+
function accountKeyForToken(apiUrl: string, accessToken: string, subject: string): string {
95+
return createHash('sha256')
96+
.update(`${apiUrl}\0token-subject:${subject}`)
97+
.digest('hex')
98+
}
99+
100+
function jwtWithSubject(subject: string, nonce: string): string {
101+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')
102+
return `${encode({ alg: 'none' })}.${encode({ sub: subject, nonce })}.sig`
103+
}
104+
94105
describe('getAccountWorkspaceId', () => {
95106
let userDataDir: string
96107

@@ -138,7 +149,8 @@ describe('getAccountWorkspaceId', () => {
138149
expect(String(calledUrl)).toBe('https://cloud.example/api/v1/auth/whoami')
139150

140151
const meta = readMeta(userDataDir)
141-
expect(meta?.accountWorkspace).toEqual({
152+
expect(meta?.accountWorkspace).toMatchObject({
153+
accountKey: expect.any(String),
142154
tokenHash: tokenHash('cld_at_abc'),
143155
workspaceId: 'ws-from-current'
144156
})
@@ -203,6 +215,40 @@ describe('getAccountWorkspaceId', () => {
203215
const { getAccountWorkspaceId } = await import('./auth')
204216
const id = await getAccountWorkspaceId()
205217

218+
expect(id).toBe('ws-cached')
219+
expect(mock.fetchMock).not.toHaveBeenCalled()
220+
expect(readMeta(userDataDir)?.accountWorkspace).toMatchObject({
221+
accountKey: expect.any(String),
222+
tokenHash: tokenHash('cld_at_cached'),
223+
workspaceId: 'ws-cached'
224+
})
225+
})
226+
227+
it('uses the cached workspace id across token rotation for the same account subject', async () => {
228+
const oldToken = jwtWithSubject('user-1', 'old')
229+
const newToken = jwtWithSubject('user-1', 'new')
230+
writeAuthJson(userDataDir, {
231+
accessToken: newToken,
232+
refreshToken: 'cld_rt_rotated',
233+
apiUrl: 'https://cloud.example'
234+
})
235+
const configDir = join(userDataDir, 'config')
236+
mkdirSync(configDir, { recursive: true })
237+
writeFileSync(
238+
join(configDir, 'auth-meta.json'),
239+
JSON.stringify({
240+
apiUrl: 'https://cloud.example',
241+
accountWorkspace: {
242+
accountKey: accountKeyForToken('https://cloud.example', oldToken, 'user-1'),
243+
tokenHash: tokenHash(oldToken),
244+
workspaceId: 'ws-cached'
245+
}
246+
})
247+
)
248+
249+
const { getAccountWorkspaceId } = await import('./auth')
250+
const id = await getAccountWorkspaceId()
251+
206252
expect(id).toBe('ws-cached')
207253
expect(mock.fetchMock).not.toHaveBeenCalled()
208254
})
@@ -239,7 +285,8 @@ describe('getAccountWorkspaceId', () => {
239285
expect(mock.fetchMock).toHaveBeenCalledTimes(1)
240286

241287
const meta = readMeta(userDataDir)
242-
expect(meta?.accountWorkspace).toEqual({
288+
expect(meta?.accountWorkspace).toMatchObject({
289+
accountKey: expect.any(String),
243290
tokenHash: tokenHash('cld_at_new'),
244291
workspaceId: 'ws-fresh'
245292
})
@@ -288,7 +335,7 @@ describe('getAccountWorkspaceId', () => {
288335
expect(mock.fetchMock).toHaveBeenCalledTimes(2)
289336
})
290337

291-
it('throws account-workspace-required when whoami responds with a non-OK status', async () => {
338+
it('throws cloud-auth-required when whoami rejects the access token', async () => {
292339
writeAuthJson(userDataDir, {
293340
accessToken: 'cld_at_401',
294341
refreshToken: 'cld_rt_401',
@@ -302,7 +349,25 @@ describe('getAccountWorkspaceId', () => {
302349
})
303350

304351
const { getAccountWorkspaceId } = await import('./auth')
305-
await expect(getAccountWorkspaceId()).rejects.toThrowError('account-workspace-required')
352+
await expect(getAccountWorkspaceId()).rejects.toThrowError('cloud-auth-required:whoami-http-401')
353+
})
354+
355+
it('throws account-workspace-required with a failure class when whoami returns a server error', async () => {
356+
writeAuthJson(userDataDir, {
357+
accessToken: 'cld_at_500',
358+
refreshToken: 'cld_rt_500',
359+
apiUrl: 'https://cloud.example'
360+
})
361+
mock.fetchMock.mockResolvedValue({
362+
ok: false,
363+
status: 500,
364+
statusText: 'Internal Server Error',
365+
json: async () => ({ error: 'No active workspace' })
366+
})
367+
368+
const { getAccountWorkspaceId } = await import('./auth')
369+
await expect(getAccountWorkspaceId({ retryAttempts: 1, retryDelayMs: 0 }))
370+
.rejects.toThrowError('account-workspace-required:whoami-http-500')
306371
})
307372
})
308373

@@ -493,3 +558,122 @@ describe('getAccessToken (refresh flow)', () => {
493558
expect(mock.fetchMock).toHaveBeenCalledTimes(1)
494559
})
495560
})
561+
562+
describe('getAuthStatus', () => {
563+
let userDataDir: string
564+
565+
beforeEach(() => {
566+
userDataDir = mkdtempSync(join(tmpdir(), 'pear-auth-status-'))
567+
mock.setUserDataDir(userDataDir)
568+
mock.readStoredAuth.mockReset()
569+
mock.readStoredAuth.mockResolvedValue(null)
570+
mock.fetchMock.mockReset()
571+
vi.stubGlobal('fetch', mock.fetchMock)
572+
vi.resetModules()
573+
})
574+
575+
afterEach(() => {
576+
vi.unstubAllGlobals()
577+
mock.clearUserDataDir()
578+
rmSync(userDataDir, { recursive: true, force: true })
579+
})
580+
581+
it('reports signed in from valid stored tokens even when profile fetch fails', async () => {
582+
writeAuthJson(userDataDir, {
583+
accessToken: 'cld_at_no_user',
584+
refreshToken: 'cld_rt_no_user',
585+
apiUrl: 'https://cloud.example'
586+
})
587+
mock.fetchMock.mockResolvedValue({
588+
ok: false,
589+
status: 401,
590+
statusText: 'Unauthorized',
591+
json: async () => ({ error: 'Unauthorized' })
592+
})
593+
594+
const { getAuthStatus } = await import('./auth')
595+
596+
await expect(getAuthStatus()).resolves.toEqual({
597+
loggedIn: true,
598+
apiUrl: 'https://cloud.example',
599+
user: undefined
600+
})
601+
})
602+
603+
it('reports signed in when whoami only returns a stable user id', async () => {
604+
writeAuthJson(userDataDir, {
605+
accessToken: 'cld_at_user_id',
606+
refreshToken: 'cld_rt_user_id',
607+
apiUrl: 'https://cloud.example'
608+
})
609+
mock.fetchMock.mockResolvedValue({
610+
ok: true,
611+
status: 200,
612+
statusText: 'OK',
613+
json: async () => ({ user: { id: 'user-1' } })
614+
})
615+
616+
const { getAuthStatus } = await import('./auth')
617+
618+
await expect(getAuthStatus()).resolves.toEqual({
619+
loggedIn: true,
620+
apiUrl: 'https://cloud.example',
621+
user: { username: 'user-1' }
622+
})
623+
})
624+
625+
it('hydrates a sparse stored profile from whoami', async () => {
626+
writeAuthJson(userDataDir, {
627+
accessToken: 'cld_at_sparse_user',
628+
refreshToken: 'cld_rt_sparse_user',
629+
apiUrl: 'https://cloud.example',
630+
user: { username: 'user-1' }
631+
})
632+
mock.fetchMock.mockResolvedValue({
633+
ok: true,
634+
status: 200,
635+
statusText: 'OK',
636+
json: async () => ({
637+
user: {
638+
id: 'user-1',
639+
name: 'Khaliq Gant',
640+
email: 'khaliq@example.test',
641+
avatarUrl: 'https://lh3.googleusercontent.com/a/avatar=s96-c'
642+
}
643+
})
644+
})
645+
646+
const { getAuthStatus } = await import('./auth')
647+
648+
await expect(getAuthStatus()).resolves.toMatchObject({
649+
loggedIn: true,
650+
apiUrl: 'https://cloud.example',
651+
user: {
652+
username: 'user-1',
653+
name: 'Khaliq Gant',
654+
email: 'khaliq@example.test',
655+
avatarUrl: 'https://lh3.googleusercontent.com/a/avatar=s96-c'
656+
}
657+
})
658+
expect(mock.fetchMock).toHaveBeenCalledWith(
659+
'https://cloud.example/api/v1/auth/whoami',
660+
expect.objectContaining({
661+
headers: { Authorization: 'Bearer cld_at_sparse_user' }
662+
})
663+
)
664+
})
665+
666+
it('does not report signed in from auth metadata when stored tokens are invalid', async () => {
667+
const configDir = join(userDataDir, 'config')
668+
mkdirSync(configDir, { recursive: true })
669+
writeFileSync(join(configDir, 'auth.json'), 'not-json')
670+
writeFileSync(join(configDir, 'auth-meta.json'), JSON.stringify({
671+
apiUrl: 'https://cloud.example',
672+
user: { name: 'Stale User', email: 'stale@example.test' }
673+
}))
674+
675+
const { getAuthStatus } = await import('./auth')
676+
677+
await expect(getAuthStatus()).resolves.toEqual({ loggedIn: false })
678+
})
679+
})

0 commit comments

Comments
 (0)