diff --git a/src/auth/__tests__/agentmd-login.test.ts b/src/auth/__tests__/agentmd-login.test.ts index 2dbaa56..316b046 100644 --- a/src/auth/__tests__/agentmd-login.test.ts +++ b/src/auth/__tests__/agentmd-login.test.ts @@ -1,5 +1,5 @@ import { expect, test, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -124,3 +124,100 @@ test('agent flags without --email are rejected before any browser flow', async ( ).rejects.toMatchObject({ exitCode: 2 }); expect(fetchMock).not.toHaveBeenCalled(); }); + +// --- AIT-131: OTP login re-validates the persisted active workspace --- + +function seedConfig(activeWorkspaceId: string, slug = 'Old Workspace') { + writeFileSync( + join(DIR, 'config.json'), + JSON.stringify({ activeWorkspaceId, activeWorkspaceSlug: slug }, null, 2), + ); +} + +function readConfig() { + return JSON.parse(readFileSync(join(DIR, 'config.json'), 'utf-8')); +} + +function fetchByUrl(workspaces: unknown[]) { + return vi.fn(async (url: unknown) => { + const u = String(url); + if (u.endsWith('/agent/auth/claim/complete')) { + return okJson({ accessToken: 'ac_live_z', tokenType: 'Bearer', scopes: ['workspace.read'], credentialPublicId: 'ac_pub3' }); + } + if (u.endsWith('/workspaces')) return okJson(workspaces); + return okJson({}); + }); +} + +test('otp login with stale workspace + one live workspace → re-resolves to it', async () => { + seedConfig('ws_stale123'); + vi.stubGlobal('fetch', fetchByUrl([{ id: 'ws_fresh456', name: 'Fresh', workosOrganizationId: 'org_1' }])); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '3333', otp: '654321', json: true }); + + expect(readConfig().activeWorkspaceId).toBe('ws_fresh456'); + expect(readConfig().activeWorkspaceSlug).toBe('Fresh'); +}); + +test('otp login with stale workspace + several live workspaces → clears the selection', async () => { + seedConfig('ws_stale123'); + vi.stubGlobal('fetch', fetchByUrl([ + { id: 'ws_fresh456', name: 'Fresh', workosOrganizationId: 'org_1' }, + { id: 'ws_other789', name: 'Other', workosOrganizationId: 'org_2' }, + ])); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '3333', otp: '654321', json: true }); + + expect(readConfig().activeWorkspaceId).toBeUndefined(); +}); + +test('otp login with a still-valid workspace → selection untouched', async () => { + seedConfig('ws_fresh456', 'Fresh'); + vi.stubGlobal('fetch', fetchByUrl([{ id: 'ws_fresh456', name: 'Fresh', workosOrganizationId: 'org_1' }])); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '3333', otp: '654321', json: true }); + + expect(readConfig().activeWorkspaceId).toBe('ws_fresh456'); + expect(readConfig().activeWorkspaceSlug).toBe('Fresh'); +}); + +test('otp login when the workspace listing fails → login still succeeds, config untouched', async () => { + seedConfig('ws_stale123'); + const fetchMock = vi.fn(async (url: unknown) => { + const u = String(url); + if (u.endsWith('/agent/auth/claim/complete')) { + return okJson({ accessToken: 'ac_live_z', tokenType: 'Bearer', scopes: ['message.send'], credentialPublicId: 'ac_pub3' }); + } + return okJson({ message: 'insufficient scope' }, 403); + }); + vi.stubGlobal('fetch', fetchMock); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '3333', otp: '654321', json: true }); + + expect(readCreds().accessToken).toBe('ac_live_z'); + expect(readConfig().activeWorkspaceId).toBe('ws_stale123'); +}); + +test('otp login with stale workspace + zero live workspaces → clears the selection', async () => { + seedConfig('ws_stale123'); + vi.stubGlobal('fetch', fetchByUrl([])); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '3333', otp: '654321', json: true }); + + expect(readConfig().activeWorkspaceId).toBeUndefined(); +}); + +test('otp login when /workspaces returns a non-array 2xx → config untouched', async () => { + seedConfig('ws_stale123'); + vi.stubGlobal('fetch', fetchByUrl({ workspaces: [] } as unknown as unknown[])); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '3333', otp: '654321', json: true }); + + expect(readConfig().activeWorkspaceId).toBe('ws_stale123'); +}); diff --git a/src/auth/login.ts b/src/auth/login.ts index b12b3ab..e193923 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -588,6 +588,7 @@ async function persistAgentCredential( credentialPublicId: cred.credentialPublicId, scopes: cred.scopes, }); + await revalidateActiveWorkspace(json); if (json) { process.stdout.write( JSON.stringify({ @@ -604,6 +605,52 @@ async function persistAgentCredential( ); } +/** + * AIT-131: scripted email logins (`--registration-id --otp`) never run the + * wizard, so a stale activeWorkspaceId in config.json survives a backend + * reset and the first post-login command 404s. After the new credential is + * saved, check the stored selection against the live workspace list: keep a + * valid one, adopt the only workspace when there is exactly one, otherwise + * clear it so the next command falls back to the picker/hint path. + * Non-fatal by design — a scope-limited credential must never fail login. + */ +async function revalidateActiveWorkspace(json?: boolean): Promise { + const { readWorkspaceConfig, writeWorkspaceConfig } = await import( + '../commands/workspace.js' + ); + const existing = readWorkspaceConfig(); + if (!existing.activeWorkspaceId) return; + let workspaces: unknown; + try { + const { apiClient } = await import('../api/client.js'); + workspaces = await apiClient('/workspaces'); + } catch { + // Workspace listing can fail for scope-limited credentials; the login + // itself succeeded, so let the next command surface any real error. + return; + } + // Bad 2xx shape → keep the config untouched rather than clearing on junk. + if (!Array.isArray(workspaces)) return; + const list = workspaces as Workspace[]; + if (list.some((w) => w.id === existing.activeWorkspaceId)) return; + const only = list.length === 1 ? list[0] : undefined; + writeWorkspaceConfig({ + activeWorkspaceId: only?.id, + activeWorkspaceSlug: only?.name, + }); + if (!json) { + const hint = + list.length === 0 + ? `Run: ${c.dim(`${cliCommandPrefix()} workspace new `)}` + : `Run: ${c.dim(`${cliCommandPrefix()} workspace use `)}`; + console.log( + only + ? `${c.warn('!')} Previous workspace no longer exists; switched to ${c.dim(only.name)}` + : `${c.warn('!')} Previous workspace no longer exists. ${hint}`, + ); + } +} + export function loginCommand(program: Command): void { const login = program .command('login')