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
99 changes: 98 additions & 1 deletion src/auth/__tests__/agentmd-login.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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');
});
47 changes: 47 additions & 0 deletions src/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ async function persistAgentCredential(
credentialPublicId: cred.credentialPublicId,
scopes: cred.scopes,
});
await revalidateActiveWorkspace(json);
if (json) {
process.stdout.write(
JSON.stringify({
Expand All @@ -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<void> {
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 <name>`)}`
: `Run: ${c.dim(`${cliCommandPrefix()} workspace use <name>`)}`;
console.log(
only
? `${c.warn('!')} Previous workspace no longer exists; switched to ${c.dim(only.name)}`
: `${c.warn('!')} Previous workspace no longer exists. ${hint}`,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function loginCommand(program: Command): void {
const login = program
.command('login')
Expand Down
Loading