From 1238ab50b9558c54de0c09001a01efe530e469e0 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Sat, 11 Jul 2026 15:10:44 +0300 Subject: [PATCH 1/2] fix(login): re-validate the active workspace after scripted email login (AIT-131) Scripted otp logins (--registration-id --otp) never run the post-login wizard, so a stale activeWorkspaceId in config.json survived a backend reset and the first post-login command 404ed. After the credential is saved, check the stored selection against the live workspace list: keep a valid one, adopt the only workspace when exactly one exists, otherwise clear the selection so the next command falls back to the picker/hint. Workspace-listing failures (scope-limited credentials) never fail login. --- src/auth/__tests__/agentmd-login.test.ts | 78 ++++++++++++++++++++++++ src/auth/login.ts | 40 ++++++++++++ 2 files changed, 118 insertions(+) diff --git a/src/auth/__tests__/agentmd-login.test.ts b/src/auth/__tests__/agentmd-login.test.ts index 2dbaa56..548720d 100644 --- a/src/auth/__tests__/agentmd-login.test.ts +++ b/src/auth/__tests__/agentmd-login.test.ts @@ -124,3 +124,81 @@ 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') { + const { writeFileSync } = require('node:fs') as typeof import('node:fs'); + 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'); +}); diff --git a/src/auth/login.ts b/src/auth/login.ts index b12b3ab..c8cfa2f 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,45 @@ 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 { + try { + const { readWorkspaceConfig, writeWorkspaceConfig } = await import( + '../commands/workspace.js' + ); + const existing = readWorkspaceConfig(); + if (!existing.activeWorkspaceId) return; + const { apiClient } = await import('../api/client.js'); + const workspaces = (await apiClient('/workspaces')) as Workspace[]; + if (workspaces.some((w) => w.id === existing.activeWorkspaceId)) return; + const only = workspaces.length === 1 ? workspaces[0] : undefined; + writeWorkspaceConfig({ + activeWorkspaceId: only?.id, + activeWorkspaceSlug: only?.name, + }); + if (!json) { + console.log( + only + ? `${c.warn('!')} Previous workspace no longer exists; switched to ${c.dim(only.name)}` + : `${c.warn('!')} Previous workspace no longer exists. Run: ${c.dim( + `${cliCommandPrefix()} workspace use `, + )}`, + ); + } + } catch { + // Workspace listing can fail for scope-limited credentials; the login + // itself succeeded, so let the next command surface any real error. + } +} + export function loginCommand(program: Command): void { const login = program .command('login') From 61361515759410136cccdd6ab054294e47d6f5bc Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Sat, 11 Jul 2026 15:20:00 +0300 Subject: [PATCH 2/2] fix(login): harden workspace re-validation per PR #14 review (AIT-131) Catch only the /workspaces listing (scope-limited credentials), guard against a non-array 2xx body, let config-write failures surface, and point the zero-workspace hint at 'workspace new' instead of 'use'. --- src/auth/__tests__/agentmd-login.test.ts | 23 ++++++++++- src/auth/login.ts | 49 ++++++++++++++---------- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/auth/__tests__/agentmd-login.test.ts b/src/auth/__tests__/agentmd-login.test.ts index 548720d..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'; @@ -128,7 +128,6 @@ test('agent flags without --email are rejected before any browser flow', async ( // --- AIT-131: OTP login re-validates the persisted active workspace --- function seedConfig(activeWorkspaceId: string, slug = 'Old Workspace') { - const { writeFileSync } = require('node:fs') as typeof import('node:fs'); writeFileSync( join(DIR, 'config.json'), JSON.stringify({ activeWorkspaceId, activeWorkspaceSlug: slug }, null, 2), @@ -202,3 +201,23 @@ test('otp login when the workspace listing fails → login still succeeds, confi 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 c8cfa2f..e193923 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -615,32 +615,39 @@ async function persistAgentCredential( * 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 { readWorkspaceConfig, writeWorkspaceConfig } = await import( - '../commands/workspace.js' - ); - const existing = readWorkspaceConfig(); - if (!existing.activeWorkspaceId) return; const { apiClient } = await import('../api/client.js'); - const workspaces = (await apiClient('/workspaces')) as Workspace[]; - if (workspaces.some((w) => w.id === existing.activeWorkspaceId)) return; - const only = workspaces.length === 1 ? workspaces[0] : undefined; - writeWorkspaceConfig({ - activeWorkspaceId: only?.id, - activeWorkspaceSlug: only?.name, - }); - if (!json) { - console.log( - only - ? `${c.warn('!')} Previous workspace no longer exists; switched to ${c.dim(only.name)}` - : `${c.warn('!')} Previous workspace no longer exists. Run: ${c.dim( - `${cliCommandPrefix()} workspace use `, - )}`, - ); - } + 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}`, + ); } }