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
145 changes: 145 additions & 0 deletions src/__tests__/fix-3306-orphan-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* fix-3306-orphan-session.test.ts — Issue #3306: Prevent orphaned server sessions
* when CLI auth fails.
*
* Tests that both `ag run` and `ag "brief"` check auth BEFORE creating a session,
* so no orphaned sessions remain on the server when auth is rejected.
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';

// Mock fetch before importing modules that use it
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);

// Mock fs/config modules
vi.mock('node:fs', () => ({
existsSync: vi.fn(() => false),
}));

vi.mock('../config.js', () => ({
loadConfig: vi.fn(async () => ({
baseUrl: 'http://127.0.0.1:9100',
authToken: 'test-admin-token',
clientAuthToken: undefined,
stateDir: '/tmp/.aegis',
})),
readConfigFile: vi.fn(async () => null),
writeConfigFile: vi.fn(),
serializeConfigFile: vi.fn(),
findConfigFilePath: vi.fn(() => null),
}));

vi.mock('../base-url.js', () => ({
deriveBaseUrl: vi.fn(() => 'http://127.0.0.1:9100'),
getConfiguredBaseUrl: vi.fn(() => 'http://127.0.0.1:9100'),
normalizeBaseUrl: vi.fn(),
}));

vi.mock('../services/auth/index.js', () => ({
AuthManager: vi.fn().mockImplementation(() => ({
load: vi.fn(),
createKey: vi.fn(async () => ({ key: 'test-key-123' })),
})),
}));

import { handleRun } from '../commands/run.js';

function makeIO() {
const stdout = { write: vi.fn() } as unknown as NodeJS.WritableStream;
const stderr = { write: vi.fn() } as unknown as NodeJS.WritableStream;
const stdin = {} as NodeJS.ReadableStream;
return { stdin, stdout, stderr };
}

function stderrOutput(io: ReturnType<typeof makeIO>): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = (io.stderr as any).write;
return w.mock.calls.map((c: any[]) => c[0]).join('');
}

describe('Issue #3306 — orphaned session on auth failure', () => {
beforeEach(() => {
mockFetch.mockReset();
});

it('handleRun should check auth before creating session (preflight)', async () => {
// Health check succeeds (no auth required)
// Preflight auth check returns 401
mockFetch.mockImplementation(async (url: string) => {
if (typeof url === 'string' && url.includes('/v1/health')) {
return { ok: true, status: 200, json: async () => ({ status: 'ok' }) };
}
if (typeof url === 'string' && url.includes('/v1/sessions/stats')) {
return { ok: false, status: 401, json: async () => ({ error: 'Unauthorized' }) };
}
return { ok: true, status: 200, json: async () => ({}) };
});

const io = makeIO();
const exitCode = await handleRun(['test'], io);

expect(exitCode).toBe(1);
expect(stderrOutput(io)).toContain('Unauthorized');

// Verify no session creation was attempted (only health + stats calls)
const postCalls = mockFetch.mock.calls.filter(
(c: unknown[]) => typeof c[1] === 'object' && (c[1] as { method?: string }).method === 'POST',
);
expect(postCalls.length).toBe(0);
});

it('handleRun should proceed when preflight auth succeeds', async () => {
mockFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (typeof url === 'string' && url.includes('/v1/health')) {
return { ok: true, status: 200, json: async () => ({ status: 'ok' }) };
}
if (typeof url === 'string' && url.includes('/v1/sessions/stats')) {
return { ok: true, status: 200, json: async () => ({ sessions: 0 }) };
}
if (typeof url === 'string' && url.includes('/v1/sessions') && opts?.method === 'POST') {
return {
ok: true,
status: 201,
json: async () => ({ id: 'test-session-id', displayName: 'run-test', promptDelivery: { status: 'delivered' } }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
});

const io = makeIO();
const exitCode = await handleRun(['--no-stream', 'build feature'], io);

expect(exitCode).toBe(0);

// Verify session creation was attempted
const postCalls = mockFetch.mock.calls.filter(
(c: unknown[]) => typeof c[1] === 'object' && (c[1] as { method?: string }).method === 'POST',
);
expect(postCalls.length).toBeGreaterThanOrEqual(1);
});

it('handleRun should proceed without preflight when no token is available', async () => {
// Server health OK, no auth token → skip preflight
mockFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (typeof url === 'string' && url.includes('/v1/health')) {
return { ok: true, status: 200, json: async () => ({ status: 'ok' }) };
}
if (typeof url === 'string' && url.includes('/v1/sessions') && opts?.method === 'POST') {
return {
ok: true,
status: 201,
json: async () => ({ id: 'test-session-id', displayName: 'run-test', promptDelivery: { status: 'delivered' } }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
});

const io = makeIO();
// No env token set
delete process.env.AEGIS_AUTH_TOKEN;
delete process.env.AEGIS_TOKEN;
const exitCode = await handleRun(['--no-stream', 'test'], io);

expect(exitCode).toBe(0);
});
});
17 changes: 17 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,23 @@ async function handleCreate(args: string[], io: CliIO): Promise<number> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;

// Issue #3306: Preflight auth check to prevent orphaned sessions.
if (authToken) {
try {
const authRes = await fetch(`${baseUrl}/v1/sessions/stats`, {
headers: { 'Authorization': `Bearer ${authToken}` },
signal: AbortSignal.timeout(5000),
});
if (authRes.status === 401) {
writeLine(io.stderr, ' ❌ Unauthorized — the server rejected the auth token.');
writeLine(io.stderr, ' Run `ag init` or set AEGIS_AUTH_TOKEN=<your-key>.');
return 1;
}
} catch {
// Network error — proceed, session creation will fail anyway
}
}

let sessionId: string;
try {
const res = await fetch(`${baseUrl}/v1/sessions`, {
Expand Down
35 changes: 35 additions & 0 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@ function resolveAuthToken(): string | undefined {
return process.env.AEGIS_AUTH_TOKEN || process.env.AEGIS_TOKEN || undefined;
}

/**
* Issue #3306: Verify auth credentials against the server before attempting
* to create a session. Prevents orphaned sessions when the CLI has an invalid
* or missing token but the server requires authentication.
* Returns true if auth is OK (or server doesn't require auth), false on 401.
*/
async function verifyAuth(baseUrl: string, authToken: string | undefined): Promise<{ ok: boolean; status?: number }> {
try {
const headers: Record<string, string> = {};
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const res = await fetch(`${baseUrl}/v1/sessions/stats`, { headers, signal: AbortSignal.timeout(5000) });
if (res.ok) return { ok: true };
if (res.status === 401) return { ok: false, status: 401 };
return { ok: true };
} catch {
return { ok: true };
}
}

/** Check if the server is healthy at the given base URL. */
async function isServerHealthy(baseUrl: string, authToken?: string): Promise<boolean> {
try {
Expand Down Expand Up @@ -240,6 +259,22 @@ export async function handleRun(args: string[], io: CliIO): Promise<number> {
}
}

// Issue #3306: Preflight auth check before creating a session.
// Prevents orphaned server sessions when the CLI token is invalid.
if (serverRunning && authToken) {
const authCheck = await verifyAuth(baseUrl, authToken);
if (!authCheck.ok) {
writeLine(io.stderr, '');
writeLine(io.stderr, ' ❌ Unauthorized — the server rejected the auth token.');
writeLine(io.stderr, '');
writeLine(io.stderr, ' To fix this:');
writeLine(io.stderr, ' 1. Run `ag init` to create a new API key and config');
writeLine(io.stderr, ' 2. Or set AEGIS_AUTH_TOKEN=<your-key> in your environment');
writeLine(io.stderr, '');
return 1;
}
}

// Create session
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
Expand Down
Loading