fix(team): refresh codex preflight runtime state
This commit is contained in:
parent
447d5fb758
commit
99e8e2e017
7 changed files with 540 additions and 7 deletions
|
|
@ -232,9 +232,11 @@ export class CodexBinaryResolver {
|
||||||
|
|
||||||
private static async runResolve(): Promise<string | null> {
|
private static async runResolve(): Promise<string | null> {
|
||||||
const override = process.env.CODEX_CLI_PATH?.trim();
|
const override = process.env.CODEX_CLI_PATH?.trim();
|
||||||
|
const shellOverride = getCachedShellEnv()?.CODEX_CLI_PATH?.trim();
|
||||||
const appManagedBinaryPath = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
const appManagedBinaryPath = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
||||||
const candidates = [
|
const candidates = [
|
||||||
...(override ? [override] : []),
|
...(override ? [override] : []),
|
||||||
|
...(shellOverride && shellOverride !== override ? [shellOverride] : []),
|
||||||
...(appManagedBinaryPath ? [appManagedBinaryPath] : []),
|
...(appManagedBinaryPath ? [appManagedBinaryPath] : []),
|
||||||
'codex',
|
'codex',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,31 @@ describe('CodexBinaryResolver', () => {
|
||||||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
|
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses CODEX_CLI_PATH from cached shell env before app-managed and PATH lookup', async () => {
|
||||||
|
setPlatform('darwin');
|
||||||
|
delete process.env.CODEX_CLI_PATH;
|
||||||
|
process.env.PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
|
||||||
|
const shellBinary = '/Users/tester/.local/bin/codex';
|
||||||
|
const appManagedBinary = '/Users/tester/.agent-teams-ai/data/runtimes/codex/current/codex';
|
||||||
|
getCachedShellEnvMock.mockReturnValue({
|
||||||
|
CODEX_CLI_PATH: shellBinary,
|
||||||
|
PATH: '/opt/homebrew/bin:/usr/bin:/bin',
|
||||||
|
});
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(appManagedBinary);
|
||||||
|
|
||||||
|
accessMock.mockImplementation((filePath) => {
|
||||||
|
if (filePath === shellBinary || filePath === appManagedBinary) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
|
||||||
|
CodexBinaryResolver.clearCache();
|
||||||
|
|
||||||
|
await expect(CodexBinaryResolver.resolve()).resolves.toBe(shellBinary);
|
||||||
|
});
|
||||||
|
|
||||||
it('prefers a verified app-managed Codex binary before PATH lookup', async () => {
|
it('prefers a verified app-managed Codex binary before PATH lookup', async () => {
|
||||||
const appManagedBinary = 'C:\\Users\\tester\\AppData\\Roaming\\AgentTeams\\codex.exe';
|
const appManagedBinary = 'C:\\Users\\tester\\AppData\\Roaming\\AgentTeams\\codex.exe';
|
||||||
const pathBinary = 'C:\\Program Files\\nodejs\\codex.cmd';
|
const pathBinary = 'C:\\Program Files\\nodejs\\codex.cmd';
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,10 @@ type AnthropicApiKeyVerifier = (
|
||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
) => Promise<AnthropicApiKeyVerificationResult>;
|
) => Promise<AnthropicApiKeyVerificationResult>;
|
||||||
|
|
||||||
|
type CodexAccountSnapshotReader = Pick<CodexAccountFeatureFacade, 'getSnapshot'> & {
|
||||||
|
refreshSnapshot?: CodexAccountFeatureFacade['refreshSnapshot'];
|
||||||
|
};
|
||||||
|
|
||||||
interface ProviderStatusEnrichmentOptions {
|
interface ProviderStatusEnrichmentOptions {
|
||||||
hydrateModelCatalog?: boolean;
|
hydrateModelCatalog?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +311,7 @@ async function checkCodexCliLoginStatus({
|
||||||
|
|
||||||
export class ProviderConnectionService {
|
export class ProviderConnectionService {
|
||||||
private static instance: ProviderConnectionService | null = null;
|
private static instance: ProviderConnectionService | null = null;
|
||||||
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
private codexAccountFeature: CodexAccountSnapshotReader | null = null;
|
||||||
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null =
|
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null =
|
||||||
null;
|
null;
|
||||||
private readonly anthropicApiKeyVerificationCache = new Map<
|
private readonly anthropicApiKeyVerificationCache = new Map<
|
||||||
|
|
@ -327,7 +331,7 @@ export class ProviderConnectionService {
|
||||||
return ProviderConnectionService.instance;
|
return ProviderConnectionService.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCodexAccountFeature(feature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null): void {
|
setCodexAccountFeature(feature: CodexAccountSnapshotReader | null): void {
|
||||||
this.codexAccountFeature = feature;
|
this.codexAccountFeature = feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -427,7 +431,9 @@ export class ProviderConnectionService {
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||||
|
refreshRuntimeMissing: true,
|
||||||
|
});
|
||||||
applyCodexRuntimeContextEnv(env, snapshot);
|
applyCodexRuntimeContextEnv(env, snapshot);
|
||||||
const readiness = evaluateCodexLaunchReadiness({
|
const readiness = evaluateCodexLaunchReadiness({
|
||||||
preferredAuthMode: snapshot.preferredAuthMode,
|
preferredAuthMode: snapshot.preferredAuthMode,
|
||||||
|
|
@ -503,7 +509,9 @@ export class ProviderConnectionService {
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||||
|
refreshRuntimeMissing: true,
|
||||||
|
});
|
||||||
applyCodexRuntimeContextEnv(env, snapshot);
|
applyCodexRuntimeContextEnv(env, snapshot);
|
||||||
const readiness = evaluateCodexLaunchReadiness({
|
const readiness = evaluateCodexLaunchReadiness({
|
||||||
preferredAuthMode: snapshot.preferredAuthMode,
|
preferredAuthMode: snapshot.preferredAuthMode,
|
||||||
|
|
@ -572,7 +580,9 @@ export class ProviderConnectionService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||||
|
refreshRuntimeMissing: true,
|
||||||
|
});
|
||||||
const runtimeEnv = { ...env };
|
const runtimeEnv = { ...env };
|
||||||
applyCodexRuntimeContextEnv(runtimeEnv, snapshot);
|
applyCodexRuntimeContextEnv(runtimeEnv, snapshot);
|
||||||
const readiness = evaluateCodexLaunchReadiness({
|
const readiness = evaluateCodexLaunchReadiness({
|
||||||
|
|
@ -681,7 +691,9 @@ export class ProviderConnectionService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||||
|
refreshRuntimeMissing: true,
|
||||||
|
});
|
||||||
const readiness = evaluateCodexLaunchReadiness({
|
const readiness = evaluateCodexLaunchReadiness({
|
||||||
preferredAuthMode: snapshot.preferredAuthMode,
|
preferredAuthMode: snapshot.preferredAuthMode,
|
||||||
managedAccount: snapshot.managedAccount,
|
managedAccount: snapshot.managedAccount,
|
||||||
|
|
@ -985,8 +997,13 @@ export class ProviderConnectionService {
|
||||||
return CODEX_NATIVE_BACKEND_ID;
|
return CODEX_NATIVE_BACKEND_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getCodexAccountSnapshot(): Promise<CodexAccountSnapshotDto> {
|
private async getCodexAccountSnapshot(options?: {
|
||||||
|
forceRefresh?: boolean;
|
||||||
|
}): Promise<CodexAccountSnapshotDto> {
|
||||||
if (this.codexAccountFeature) {
|
if (this.codexAccountFeature) {
|
||||||
|
if (options?.forceRefresh && this.codexAccountFeature.refreshSnapshot) {
|
||||||
|
return this.codexAccountFeature.refreshSnapshot({ forceRefreshToken: true });
|
||||||
|
}
|
||||||
return this.codexAccountFeature.getSnapshot();
|
return this.codexAccountFeature.getSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1042,6 +1059,27 @@ export class ProviderConnectionService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getCodexLaunchSnapshot(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
options?: { refreshRuntimeMissing?: boolean }
|
||||||
|
): Promise<CodexAccountSnapshotDto> {
|
||||||
|
let snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
|
||||||
|
if (!options?.refreshRuntimeMissing || snapshot.appServerState !== 'runtime-missing') {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
snapshot = this.mergeCodexApiKeyAvailability(
|
||||||
|
await this.getCodexAccountSnapshot({ forceRefresh: true }),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Keep the original runtime-missing snapshot so callers still report the concrete issue.
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveCodexApiKeyValue(
|
private async resolveCodexApiKeyValue(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
runtimeBackendOverride?: string | null,
|
runtimeBackendOverride?: string | null,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||||
|
|
||||||
const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>();
|
const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>();
|
||||||
const execCliMock = vi.fn<
|
const execCliMock = vi.fn<
|
||||||
(
|
(
|
||||||
|
|
@ -57,6 +59,67 @@ describe('ProviderConnectionService', () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCodexSnapshot(
|
||||||
|
overrides: Partial<CodexAccountSnapshotDto> = {}
|
||||||
|
): CodexAccountSnapshotDto {
|
||||||
|
return {
|
||||||
|
preferredAuthMode: 'auto',
|
||||||
|
effectiveAuthMode: 'chatgpt',
|
||||||
|
launchAllowed: true,
|
||||||
|
launchIssueMessage: null,
|
||||||
|
launchReadinessState: 'ready_chatgpt',
|
||||||
|
appServerState: 'healthy',
|
||||||
|
appServerStatusMessage: null,
|
||||||
|
managedAccount: {
|
||||||
|
type: 'chatgpt',
|
||||||
|
email: 'user@example.com',
|
||||||
|
planType: 'pro',
|
||||||
|
},
|
||||||
|
apiKey: {
|
||||||
|
available: false,
|
||||||
|
source: null,
|
||||||
|
sourceLabel: null,
|
||||||
|
},
|
||||||
|
requiresOpenaiAuth: false,
|
||||||
|
localAccountArtifactsPresent: true,
|
||||||
|
localActiveChatgptAccountPresent: true,
|
||||||
|
runtimeContext: {
|
||||||
|
binaryPath: '/opt/codex/bin/codex',
|
||||||
|
codexHome: '/Users/tester/.codex-custom',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
startedAt: null,
|
||||||
|
},
|
||||||
|
rateLimits: null,
|
||||||
|
updatedAt: '2026-04-20T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCodexRuntimeMissingSnapshot(
|
||||||
|
overrides: Partial<CodexAccountSnapshotDto> = {}
|
||||||
|
): CodexAccountSnapshotDto {
|
||||||
|
return createCodexSnapshot({
|
||||||
|
effectiveAuthMode: null,
|
||||||
|
launchAllowed: false,
|
||||||
|
launchIssueMessage: 'Codex CLI not found',
|
||||||
|
launchReadinessState: 'runtime_missing',
|
||||||
|
appServerState: 'runtime-missing',
|
||||||
|
appServerStatusMessage: 'Codex CLI not found',
|
||||||
|
managedAccount: null,
|
||||||
|
requiresOpenaiAuth: null,
|
||||||
|
localAccountArtifactsPresent: false,
|
||||||
|
localActiveChatgptAccountPresent: false,
|
||||||
|
runtimeContext: {
|
||||||
|
binaryPath: null,
|
||||||
|
codexHome: null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -922,6 +985,170 @@ describe('ProviderConnectionService', () => {
|
||||||
expect(issue).toContain('Codex native requires OPENAI_API_KEY or CODEX_API_KEY');
|
expect(issue).toContain('Codex native requires OPENAI_API_KEY or CODEX_API_KEY');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes a runtime-missing Codex snapshot before blocking launch preflight', async () => {
|
||||||
|
const { ProviderConnectionService } =
|
||||||
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
||||||
|
const runtimeMissingSnapshot = createCodexRuntimeMissingSnapshot();
|
||||||
|
const refreshSnapshot = vi.fn().mockResolvedValue(
|
||||||
|
createCodexSnapshot({
|
||||||
|
effectiveAuthMode: 'api_key',
|
||||||
|
launchReadinessState: 'ready_api_key',
|
||||||
|
managedAccount: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ProviderConnectionService(
|
||||||
|
{
|
||||||
|
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getConfig: () => createConfig('auto'),
|
||||||
|
} as never
|
||||||
|
);
|
||||||
|
service.setCodexAccountFeature({
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue(runtimeMissingSnapshot),
|
||||||
|
refreshSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issue = await service.getConfiguredConnectionIssue(
|
||||||
|
{
|
||||||
|
CODEX_API_KEY: 'native-key',
|
||||||
|
},
|
||||||
|
'codex'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(issue).toBeNull();
|
||||||
|
expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes a runtime-missing Codex snapshot before mutating strict launch env', async () => {
|
||||||
|
const { ProviderConnectionService } =
|
||||||
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
||||||
|
const refreshSnapshot = vi.fn().mockResolvedValue(createCodexSnapshot());
|
||||||
|
const service = new ProviderConnectionService(
|
||||||
|
{
|
||||||
|
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getConfig: () => createConfig('auto'),
|
||||||
|
} as never
|
||||||
|
);
|
||||||
|
service.setCodexAccountFeature({
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue(createCodexRuntimeMissingSnapshot()),
|
||||||
|
refreshSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = await service.applyConfiguredConnectionEnv(
|
||||||
|
{
|
||||||
|
OPENAI_API_KEY: 'ambient-openai-key',
|
||||||
|
CODEX_API_KEY: 'ambient-codex-key',
|
||||||
|
},
|
||||||
|
'codex'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(env.OPENAI_API_KEY).toBeUndefined();
|
||||||
|
expect(env.CODEX_API_KEY).toBeUndefined();
|
||||||
|
expect(env.CODEX_CLI_PATH).toBe('/opt/codex/bin/codex');
|
||||||
|
expect(env.CODEX_HOME).toBe('/Users/tester/.codex-custom');
|
||||||
|
expect(env.CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD).toBe('chatgpt');
|
||||||
|
expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes a runtime-missing Codex snapshot before augmenting API-key launch env', async () => {
|
||||||
|
const { ProviderConnectionService } =
|
||||||
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
||||||
|
const refreshSnapshot = vi.fn().mockResolvedValue(
|
||||||
|
createCodexSnapshot({
|
||||||
|
preferredAuthMode: 'api_key',
|
||||||
|
effectiveAuthMode: 'api_key',
|
||||||
|
launchReadinessState: 'ready_api_key',
|
||||||
|
managedAccount: null,
|
||||||
|
apiKey: {
|
||||||
|
available: true,
|
||||||
|
source: 'environment',
|
||||||
|
sourceLabel: 'Detected from CODEX_API_KEY',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const service = new ProviderConnectionService(
|
||||||
|
{
|
||||||
|
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getConfig: () => createConfig('auto'),
|
||||||
|
} as never
|
||||||
|
);
|
||||||
|
service.setCodexAccountFeature({
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue(createCodexRuntimeMissingSnapshot()),
|
||||||
|
refreshSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = await service.augmentConfiguredConnectionEnv(
|
||||||
|
{
|
||||||
|
CODEX_API_KEY: 'native-key',
|
||||||
|
},
|
||||||
|
'codex'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(env.OPENAI_API_KEY).toBe('native-key');
|
||||||
|
expect(env.CODEX_API_KEY).toBe('native-key');
|
||||||
|
expect(env.CODEX_CLI_PATH).toBe('/opt/codex/bin/codex');
|
||||||
|
expect(env.CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD).toBe('api');
|
||||||
|
expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the original runtime-missing issue when the forced Codex snapshot refresh fails', async () => {
|
||||||
|
const { ProviderConnectionService } =
|
||||||
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
||||||
|
const service = new ProviderConnectionService(
|
||||||
|
{
|
||||||
|
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getConfig: () => createConfig('auto'),
|
||||||
|
} as never
|
||||||
|
);
|
||||||
|
service.setCodexAccountFeature({
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue(createCodexRuntimeMissingSnapshot()),
|
||||||
|
refreshSnapshot: vi.fn().mockRejectedValue(new Error('refresh failed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const issue = await service.getConfiguredConnectionIssue({}, 'codex');
|
||||||
|
|
||||||
|
expect(issue).toBe('Codex CLI not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes a runtime-missing Codex snapshot before building forced launch args', async () => {
|
||||||
|
const { ProviderConnectionService } =
|
||||||
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
||||||
|
const service = new ProviderConnectionService(
|
||||||
|
{
|
||||||
|
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getConfig: () => createConfig('auto'),
|
||||||
|
} as never
|
||||||
|
);
|
||||||
|
service.setCodexAccountFeature({
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue(createCodexRuntimeMissingSnapshot()),
|
||||||
|
refreshSnapshot: vi.fn().mockResolvedValue(createCodexSnapshot()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = await service.getConfiguredConnectionLaunchArgs(
|
||||||
|
{},
|
||||||
|
'codex',
|
||||||
|
'codex-native',
|
||||||
|
'codex'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(args).toEqual(['-c', 'forced_login_method="chatgpt"']);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports a pinned Codex ChatGPT mode as a missing active CLI login instead of flattening it to generic auth advice', async () => {
|
it('reports a pinned Codex ChatGPT mode as a missing active CLI login instead of flattening it to generic auth advice', async () => {
|
||||||
const { ProviderConnectionService } =
|
const { ProviderConnectionService } =
|
||||||
await import('@main/services/runtime/ProviderConnectionService');
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,75 @@ describe('buildProviderAwareCliEnv', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes Codex env refreshed by strict credential application into launch args and issue checks', async () => {
|
||||||
|
applyConfiguredConnectionEnvMock.mockImplementation(
|
||||||
|
async (env: NodeJS.ProcessEnv, providerId: string) => {
|
||||||
|
expect(providerId).toBe('codex');
|
||||||
|
env.CODEX_CLI_PATH = '/Users/tester/.local/bin/codex';
|
||||||
|
env.CODEX_HOME = '/Users/tester/.codex-custom';
|
||||||
|
env.CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD = 'chatgpt';
|
||||||
|
delete env.OPENAI_API_KEY;
|
||||||
|
delete env.CODEX_API_KEY;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
getConfiguredConnectionLaunchArgsMock.mockResolvedValue([
|
||||||
|
'-c',
|
||||||
|
'forced_login_method="chatgpt"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { buildProviderAwareCliEnv } =
|
||||||
|
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||||
|
const result = await buildProviderAwareCliEnv({
|
||||||
|
binaryPath: '/mock/claude-multimodel',
|
||||||
|
providerId: 'codex',
|
||||||
|
env: {
|
||||||
|
OPENAI_API_KEY: 'ambient-openai-key',
|
||||||
|
CODEX_API_KEY: 'ambient-codex-key',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const launchArgsEnv = getConfiguredConnectionLaunchArgsMock.mock.calls[0]?.[0] as
|
||||||
|
| NodeJS.ProcessEnv
|
||||||
|
| undefined;
|
||||||
|
expect(launchArgsEnv).toBeDefined();
|
||||||
|
expect(launchArgsEnv).toMatchObject({
|
||||||
|
CODEX_CLI_PATH: '/Users/tester/.local/bin/codex',
|
||||||
|
CODEX_HOME: '/Users/tester/.codex-custom',
|
||||||
|
CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt',
|
||||||
|
});
|
||||||
|
expect(launchArgsEnv?.OPENAI_API_KEY).toBeUndefined();
|
||||||
|
expect(launchArgsEnv?.CODEX_API_KEY).toBeUndefined();
|
||||||
|
expect(getConfiguredConnectionLaunchArgsMock).toHaveBeenCalledWith(
|
||||||
|
launchArgsEnv,
|
||||||
|
'codex',
|
||||||
|
undefined,
|
||||||
|
'/mock/claude-multimodel'
|
||||||
|
);
|
||||||
|
const connectionIssuesEnv = getConfiguredConnectionIssuesMock.mock.calls[0]?.[0] as
|
||||||
|
| NodeJS.ProcessEnv
|
||||||
|
| undefined;
|
||||||
|
expect(connectionIssuesEnv).toBeDefined();
|
||||||
|
expect(connectionIssuesEnv).toMatchObject({
|
||||||
|
CODEX_CLI_PATH: '/Users/tester/.local/bin/codex',
|
||||||
|
CODEX_HOME: '/Users/tester/.codex-custom',
|
||||||
|
CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt',
|
||||||
|
});
|
||||||
|
expect(connectionIssuesEnv?.OPENAI_API_KEY).toBeUndefined();
|
||||||
|
expect(connectionIssuesEnv?.CODEX_API_KEY).toBeUndefined();
|
||||||
|
expect(getConfiguredConnectionIssuesMock).toHaveBeenCalledWith(
|
||||||
|
connectionIssuesEnv,
|
||||||
|
['codex'],
|
||||||
|
{ codex: undefined }
|
||||||
|
);
|
||||||
|
expect(result.env.CODEX_CLI_PATH).toBe('/Users/tester/.local/bin/codex');
|
||||||
|
expect(result.env.CODEX_HOME).toBe('/Users/tester/.codex-custom');
|
||||||
|
expect(result.env.CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD).toBe('chatgpt');
|
||||||
|
expect(result.env.OPENAI_API_KEY).toBeUndefined();
|
||||||
|
expect(result.env.CODEX_API_KEY).toBeUndefined();
|
||||||
|
expect(result.providerArgs).toEqual(['-c', 'forced_login_method="chatgpt"']);
|
||||||
|
});
|
||||||
|
|
||||||
it('injects the verified app-managed OpenCode binary for OpenCode launches', async () => {
|
it('injects the verified app-managed OpenCode binary for OpenCode launches', async () => {
|
||||||
const appManagedBinaryPath = path.join(
|
const appManagedBinaryPath = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
// @vitest-environment node
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const buildProviderAwareCliEnvMock = vi.fn();
|
||||||
|
const addTeamNotificationMock = vi.fn().mockResolvedValue(null);
|
||||||
|
|
||||||
|
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
||||||
|
ClaudeBinaryResolver: { resolve: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@main/utils/shellEnv', () => ({
|
||||||
|
resolveInteractiveShellEnv: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
|
||||||
|
buildProviderAwareCliEnv: (...args: Parameters<typeof buildProviderAwareCliEnvMock>) =>
|
||||||
|
buildProviderAwareCliEnvMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@main/utils/childProcess', () => ({
|
||||||
|
execCli: vi.fn(),
|
||||||
|
spawnCli: vi.fn(),
|
||||||
|
killProcessTree: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
||||||
|
NotificationManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
addTeamNotification: addTeamNotificationMock,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
|
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
||||||
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||||
|
|
||||||
|
type CodexProbeHarness = TeamProvisioningService & {
|
||||||
|
probeClaudeRuntime: (
|
||||||
|
claudePath: string,
|
||||||
|
cwd: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerArgs: string[]
|
||||||
|
) => Promise<{ warning?: string }>;
|
||||||
|
runProviderOneShotDiagnostic: (
|
||||||
|
claudePath: string,
|
||||||
|
cwd: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerArgs: string[]
|
||||||
|
) => Promise<{ warning?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TeamProvisioningService Codex create-team preflight', () => {
|
||||||
|
let tempRoot = '';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-codex-preflight-'));
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||||
|
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
SHELL: '/bin/zsh',
|
||||||
|
});
|
||||||
|
buildProviderAwareCliEnvMock.mockImplementation(
|
||||||
|
async ({ env, providerId }: { env: NodeJS.ProcessEnv; providerId?: string }) => {
|
||||||
|
expect(providerId).toBe('codex');
|
||||||
|
env.CODEX_CLI_PATH = '/Users/tester/.local/bin/codex';
|
||||||
|
env.CODEX_HOME = '/Users/tester/.codex-custom';
|
||||||
|
env.CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD = 'chatgpt';
|
||||||
|
return {
|
||||||
|
env,
|
||||||
|
providerArgs: ['-c', 'forced_login_method="chatgpt"'],
|
||||||
|
connectionIssues: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempRoot, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses refreshed Codex provider env for both runtime probe and deep one-shot preflight', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const harness = service as unknown as CodexProbeHarness;
|
||||||
|
const probeClaudeRuntime = vi.spyOn(harness, 'probeClaudeRuntime').mockResolvedValue({});
|
||||||
|
const runProviderOneShotDiagnostic = vi
|
||||||
|
.spyOn(harness, 'runProviderOneShotDiagnostic')
|
||||||
|
.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await service.prepareForProvisioning(tempRoot, {
|
||||||
|
forceFresh: true,
|
||||||
|
providerId: 'codex',
|
||||||
|
modelVerificationMode: 'deep',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ready).toBe(true);
|
||||||
|
expect(result.message).toBe('CLI is warmed up and ready to launch');
|
||||||
|
expect(result.warnings?.join('\n') ?? '').not.toContain('Codex CLI not found');
|
||||||
|
expect(probeClaudeRuntime).toHaveBeenCalledWith(
|
||||||
|
'/fake/claude',
|
||||||
|
tempRoot,
|
||||||
|
expect.objectContaining({
|
||||||
|
CODEX_CLI_PATH: '/Users/tester/.local/bin/codex',
|
||||||
|
CODEX_HOME: '/Users/tester/.codex-custom',
|
||||||
|
CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt',
|
||||||
|
}),
|
||||||
|
'codex',
|
||||||
|
['-c', 'forced_login_method="chatgpt"']
|
||||||
|
);
|
||||||
|
expect(runProviderOneShotDiagnostic).toHaveBeenCalledWith(
|
||||||
|
'/fake/claude',
|
||||||
|
tempRoot,
|
||||||
|
expect.objectContaining({
|
||||||
|
CODEX_CLI_PATH: '/Users/tester/.local/bin/codex',
|
||||||
|
CODEX_HOME: '/Users/tester/.codex-custom',
|
||||||
|
CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt',
|
||||||
|
}),
|
||||||
|
'codex',
|
||||||
|
['-c', 'forced_login_method="chatgpt"']
|
||||||
|
);
|
||||||
|
expect(buildProviderAwareCliEnvMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1440,6 +1440,49 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps concrete Codex runtime-missing warnings visible after model compatibility succeeds', async () => {
|
||||||
|
const prepareProvisioning = vi.fn<
|
||||||
|
(
|
||||||
|
cwd?: string,
|
||||||
|
providerId?: TeamProviderId,
|
||||||
|
providerIds?: TeamProviderId[],
|
||||||
|
selectedModels?: string[],
|
||||||
|
limitContext?: boolean,
|
||||||
|
modelVerificationMode?: 'compatibility' | 'deep'
|
||||||
|
) => Promise<TeamProvisioningPrepareResult>
|
||||||
|
>((_, __, ___, selectedModels, ____, modelVerificationMode) => {
|
||||||
|
if (selectedModels?.length === 1 && modelVerificationMode === 'compatibility') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ready: true,
|
||||||
|
message: 'CLI is ready to launch',
|
||||||
|
details: ['Selected model gpt-5.4 is available for launch.'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
ready: true,
|
||||||
|
message: 'CLI is ready to launch (see notes)',
|
||||||
|
warnings: ['Codex CLI not found. Install Codex to use native account management.'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runProviderPrepareDiagnostics({
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
providerId: 'codex',
|
||||||
|
selectedModelIds: ['gpt-5.4'],
|
||||||
|
prepareProvisioning,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('notes');
|
||||||
|
expect(result.details).toEqual([
|
||||||
|
'Codex CLI not found. Install Codex to use native account management.',
|
||||||
|
'5.4 - available for launch',
|
||||||
|
]);
|
||||||
|
expect(result.warnings).toEqual([
|
||||||
|
'Codex CLI not found. Install Codex to use native account management.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('suppresses a generic runtime preflight failure when selected models later verify', async () => {
|
it('suppresses a generic runtime preflight failure when selected models later verify', async () => {
|
||||||
const prepareProvisioning = vi.fn<
|
const prepareProvisioning = vi.fn<
|
||||||
(
|
(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue