- Remove OPENAI_API_KEY from .env and Codex child process env to prevent API billing when subscription quota is exhausted - Remove writeCodexApiKeyAuth entirely — Codex now uses OAuth only - Add 9-pattern secret redaction in formatOutbound() to prevent key leaks - Fix Codex usage bucket aggregation: use 'codex' bucket only instead of max across all buckets (bengalfox = Codex Spark, not needed) - Add d7≥100% auto-rotation in updateCodexAccountUsage to skip exhausted accounts and prevent API billing fallback - Add findNextCodexAvailable that checks both rate-limits and d7 usage - Clean stale apikey auth.json files from all session directories - Update tests: OAuth-only auth, d7 auto-skip (3 new tests), dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
176 lines
5.2 KiB
TypeScript
176 lines
5.2 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { mockReadEnvFile, mockGetActiveCodexAuthPath } = vi.hoisted(() => ({
|
|
mockReadEnvFile: vi.fn<() => Record<string, string>>(),
|
|
mockGetActiveCodexAuthPath: vi.fn<() => string | null>(),
|
|
}));
|
|
|
|
vi.mock('./config.js', () => ({
|
|
GROUPS_DIR: '/tmp/ejclaw-test-groups',
|
|
TIMEZONE: 'Asia/Seoul',
|
|
}));
|
|
|
|
vi.mock('./db.js', () => ({
|
|
isPairedRoomJid: vi.fn(() => false),
|
|
}));
|
|
|
|
vi.mock('./env.js', () => ({
|
|
readEnvFile: mockReadEnvFile,
|
|
getEnv: vi.fn((key: string) => undefined),
|
|
}));
|
|
|
|
vi.mock('./codex-token-rotation.js', () => ({
|
|
getActiveCodexAuthPath: mockGetActiveCodexAuthPath,
|
|
}));
|
|
|
|
vi.mock('./token-rotation.js', () => ({
|
|
getCurrentToken: vi.fn(() => undefined),
|
|
}));
|
|
|
|
vi.mock('./platform-prompts.js', () => ({
|
|
readPlatformPrompt: vi.fn(() => 'platform prompt'),
|
|
readPairedRoomPrompt: vi.fn(() => 'paired room prompt'),
|
|
}));
|
|
|
|
vi.mock('./group-folder.js', () => ({
|
|
resolveGroupFolderPath: (folder: string) =>
|
|
`${process.env.EJ_TEST_ROOT}/groups/${folder}`,
|
|
resolveGroupIpcPath: (folder: string) =>
|
|
`${process.env.EJ_TEST_ROOT}/ipc/${folder}`,
|
|
resolveGroupSessionsPath: (folder: string) =>
|
|
`${process.env.EJ_TEST_ROOT}/sessions/${folder}`,
|
|
resolveTaskRuntimeIpcPath: (folder: string, taskId: string) =>
|
|
`${process.env.EJ_TEST_ROOT}/task-ipc/${folder}/${taskId}`,
|
|
resolveTaskSessionsPath: (folder: string, taskId: string) =>
|
|
`${process.env.EJ_TEST_ROOT}/task-sessions/${folder}/${taskId}`,
|
|
}));
|
|
|
|
vi.mock('os', async () => {
|
|
const actual = await vi.importActual<typeof import('os')>('os');
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual,
|
|
homedir: () => process.env.EJ_TEST_HOME || '/tmp',
|
|
},
|
|
homedir: () => process.env.EJ_TEST_HOME || '/tmp',
|
|
};
|
|
});
|
|
|
|
import { prepareGroupEnvironment } from './agent-runner-environment.js';
|
|
import type { RegisteredGroup } from './types.js';
|
|
|
|
const group: RegisteredGroup = {
|
|
name: 'Codex Test Group',
|
|
folder: 'codex-test-group',
|
|
trigger: '@Codex',
|
|
added_at: new Date().toISOString(),
|
|
agentType: 'codex',
|
|
};
|
|
|
|
describe('prepareGroupEnvironment codex auth handling', () => {
|
|
let tempRoot: string;
|
|
let previousCwd: string;
|
|
let previousOpenAiKey: string | undefined;
|
|
let previousCodexOpenAiKey: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-'));
|
|
previousCwd = process.cwd();
|
|
process.chdir(tempRoot);
|
|
|
|
process.env.EJ_TEST_ROOT = tempRoot;
|
|
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
|
|
previousOpenAiKey = process.env.OPENAI_API_KEY;
|
|
previousCodexOpenAiKey = process.env.CODEX_OPENAI_API_KEY;
|
|
delete process.env.OPENAI_API_KEY;
|
|
delete process.env.CODEX_OPENAI_API_KEY;
|
|
|
|
fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true });
|
|
fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
|
|
recursive: true,
|
|
});
|
|
|
|
mockReadEnvFile.mockReset();
|
|
mockGetActiveCodexAuthPath.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(previousCwd);
|
|
delete process.env.EJ_TEST_ROOT;
|
|
delete process.env.EJ_TEST_HOME;
|
|
if (previousOpenAiKey) process.env.OPENAI_API_KEY = previousOpenAiKey;
|
|
else delete process.env.OPENAI_API_KEY;
|
|
if (previousCodexOpenAiKey) {
|
|
process.env.CODEX_OPENAI_API_KEY = previousCodexOpenAiKey;
|
|
} else {
|
|
delete process.env.CODEX_OPENAI_API_KEY;
|
|
}
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('ignores OPENAI_API_KEY and always uses OAuth auth', () => {
|
|
const rotatedAuthPath = path.join(tempRoot, 'rotated-auth.json');
|
|
const rotatedAuth = {
|
|
auth_mode: 'chatgpt',
|
|
tokens: { access_token: 'x' },
|
|
};
|
|
fs.writeFileSync(rotatedAuthPath, JSON.stringify(rotatedAuth));
|
|
mockGetActiveCodexAuthPath.mockReturnValue(rotatedAuthPath);
|
|
mockReadEnvFile.mockReturnValue({
|
|
OPENAI_API_KEY: 'sk-test-api-key',
|
|
CODEX_MODEL: 'gpt-5.4',
|
|
});
|
|
|
|
prepareGroupEnvironment(group, false, 'dc:test');
|
|
|
|
const authPath = path.join(
|
|
tempRoot,
|
|
'sessions',
|
|
group.folder,
|
|
'.codex',
|
|
'auth.json',
|
|
);
|
|
const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8')) as {
|
|
auth_mode: string;
|
|
OPENAI_API_KEY?: string;
|
|
tokens?: unknown;
|
|
};
|
|
|
|
// API key auth is never used — always OAuth
|
|
expect(auth.auth_mode).toBe('chatgpt');
|
|
expect(auth.OPENAI_API_KEY).toBeUndefined();
|
|
expect(auth.tokens).toEqual({ access_token: 'x' });
|
|
});
|
|
|
|
it('falls back to rotated OAuth auth when no API key is configured', () => {
|
|
const rotatedAuthPath = path.join(tempRoot, 'rotated-auth.json');
|
|
const rotatedAuth = {
|
|
auth_mode: 'chatgpt',
|
|
tokens: {
|
|
access_token: 'oauth-access',
|
|
refresh_token: 'oauth-refresh',
|
|
},
|
|
};
|
|
fs.writeFileSync(rotatedAuthPath, JSON.stringify(rotatedAuth));
|
|
mockGetActiveCodexAuthPath.mockReturnValue(rotatedAuthPath);
|
|
mockReadEnvFile.mockReturnValue({});
|
|
|
|
prepareGroupEnvironment(group, false, 'dc:test');
|
|
|
|
const authPath = path.join(
|
|
tempRoot,
|
|
'sessions',
|
|
group.folder,
|
|
'.codex',
|
|
'auth.json',
|
|
);
|
|
const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
|
|
expect(auth).toEqual(rotatedAuth);
|
|
});
|
|
});
|