diff --git a/runtime.lock.json b/runtime.lock.json index c4ef8eda..492a0d16 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.38", - "sourceRef": "v0.0.38", + "version": "0.0.39", + "sourceRef": "v0.0.39", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/agent-teams-ai", "releaseTag": "v2.0.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.38.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.39.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.38.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.39.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.38.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.39.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.38.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.39.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index e67aa484..a248c49f 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -22,7 +22,7 @@ import { CodexBinaryResolver, JsonRpcStdioClient, } from '@main/services/infrastructure/codexAppServer'; -import { getCachedShellEnv } from '@main/utils/shellEnv'; +import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv'; import { CodexAccountSnapshotPresenter } from '../adapters/output/presenters/CodexAccountSnapshotPresenter'; import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient'; @@ -41,6 +41,7 @@ type LoggerPort = Pick; const SNAPSHOT_CACHE_TTL_MS = 5_000; const RATE_LIMITS_CACHE_TTL_MS = 45_000; const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_000; +const CODEX_BINARY_COLD_RETRY_TIMEOUT_MS = 12_000; interface CodexLastKnownAccount { payload: CodexAppServerGetAccountResponse; @@ -251,6 +252,20 @@ function createDeferred(): { promise: Promise; resolve: () => void } { }; } +async function resolveCodexBinaryForAccountSnapshot(): Promise { + const binaryPath = await CodexBinaryResolver.resolve(); + if (binaryPath) { + return binaryPath; + } + + await resolveInteractiveShellEnvBestEffort({ + timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS, + fallbackEnv: process.env, + }); + CodexBinaryResolver.clearCache(); + return CodexBinaryResolver.resolve(); +} + export interface CodexAccountFeatureFacade { getSnapshot(): Promise; refreshSnapshot(options?: { @@ -351,7 +366,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { }): Promise { let binaryMissing = false; await this.runSerializedMutation(async () => { - const binaryPath = await CodexBinaryResolver.resolve(); + const binaryPath = await resolveCodexBinaryForAccountSnapshot(); if (!binaryPath) { binaryMissing = true; return; @@ -380,7 +395,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { await this.runSerializedMutation(async () => { await this.loginSessionManager.cancel().catch(() => undefined); - const binaryPath = await CodexBinaryResolver.resolve(); + const binaryPath = await resolveCodexBinaryForAccountSnapshot(); if (!binaryPath) { throw new Error('Codex CLI is not available, so logout cannot be completed.'); } @@ -467,7 +482,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { const localAccountState = await detectCodexLocalAccountState(); const localAccountArtifactsPresent = localAccountState.hasArtifacts; const localActiveChatgptAccountPresent = localAccountState.hasActiveChatgptAccount; - const binaryPath = await CodexBinaryResolver.resolve(); + const binaryPath = await resolveCodexBinaryForAccountSnapshot(); const login = this.loginSessionManager.getState(); const now = Date.now(); diff --git a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts index 5649df12..0537b1ab 100644 --- a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts +++ b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts @@ -15,6 +15,7 @@ const BINARY_LAUNCH_VERIFY_TIMEOUT_MS = 3_000; let cachedBinaryPath: string | null | undefined; let cacheVerifiedAt = 0; let resolveInFlight: Promise | null = null; +let cachedMissHadShellEnv = false; const versionCache = new Map(); async function fileExists(filePath: string): Promise { @@ -121,24 +122,33 @@ export class CodexBinaryResolver { cachedBinaryPath = undefined; cacheVerifiedAt = 0; resolveInFlight = null; + cachedMissHadShellEnv = false; versionCache.clear(); } static async resolve(): Promise { if (cachedBinaryPath !== undefined) { if (cachedBinaryPath === null) { - const verifiedAppManagedBinaryPath = - await resolveVerifiedAppManagedCodexRuntimeBinaryPath(); - if (verifiedAppManagedBinaryPath) { - cachedBinaryPath = verifiedAppManagedBinaryPath; - cacheVerifiedAt = Date.now(); - return verifiedAppManagedBinaryPath; + if (!cachedMissHadShellEnv && getCachedShellEnv() !== null) { + cachedBinaryPath = undefined; + cacheVerifiedAt = 0; + cachedMissHadShellEnv = false; + } else { + const verifiedAppManagedBinaryPath = + await resolveVerifiedAppManagedCodexRuntimeBinaryPath(); + if (verifiedAppManagedBinaryPath) { + cachedBinaryPath = verifiedAppManagedBinaryPath; + cacheVerifiedAt = Date.now(); + cachedMissHadShellEnv = false; + return verifiedAppManagedBinaryPath; + } + if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) { + return null; + } + cachedBinaryPath = undefined; + cacheVerifiedAt = 0; + cachedMissHadShellEnv = false; } - if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) { - return null; - } - cachedBinaryPath = undefined; - cacheVerifiedAt = 0; } else { if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) { return cachedBinaryPath; @@ -147,6 +157,7 @@ export class CodexBinaryResolver { const verified = await verifyBinary(cachedBinaryPath); if (verified) { cacheVerifiedAt = Date.now(); + cachedMissHadShellEnv = false; return verified; } @@ -178,12 +189,14 @@ export class CodexBinaryResolver { if (resolved) { cachedBinaryPath = resolved; cacheVerifiedAt = Date.now(); + cachedMissHadShellEnv = false; return resolved; } } cachedBinaryPath = null; cacheVerifiedAt = Date.now(); + cachedMissHadShellEnv = getCachedShellEnv() !== null; return null; } diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts index 73e521f4..a1d4c07c 100644 --- a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts +++ b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts @@ -205,6 +205,34 @@ describe('CodexBinaryResolver', () => { await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim); }); + it('recovers a cold negative cache entry as soon as shell env becomes available', async () => { + setPlatform('darwin'); + process.env.PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; + const shellPath = '/usr/local/bin:/usr/bin:/bin'; + const codexShim = path.posix.join('/usr/local/bin', 'codex'); + buildMergedCliPathMock.mockReturnValue('/usr/bin:/bin:/usr/sbin:/sbin'); + + accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + await expect(CodexBinaryResolver.resolve()).resolves.toBeNull(); + + getCachedShellEnvMock.mockReturnValue({ + HOME: '/Users/tester', + PATH: shellPath, + }); + accessMock.mockImplementation((filePath) => { + if (filePath === codexShim) { + return Promise.resolve(); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim); + }); + it('skips Windows PATH candidates that exist but cannot be launched', async () => { const blockedDir = 'C:\\Program Files\\WindowsApps\\OpenAI.Codex_26.422.3464.0_x64__2p2nqsd0c76g0\\app\\resources'; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a72bfb0b..f5e172be 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -20061,14 +20061,28 @@ export class TeamProvisioningService { ): Promise { const { stdout } = await execCli( claudePath, - buildProviderCliCommandArgs(providerArgs, ['model', 'list', '--json', '--provider', 'all']), + buildProviderCliCommandArgs(providerArgs, [ + 'model', + 'list', + '--json', + '--provider', + providerId, + ]), { cwd, env, timeout: 10_000, } ); - const parsed = extractJsonObjectFromCli(stdout); + let parsed: ProviderModelListCommandResponse; + try { + parsed = extractJsonObjectFromCli(stdout); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to parse runtime default model list for ${getTeamProviderLabel(providerId)} (${providerId}): ${message}` + ); + } const defaultModel = parsed.providers?.[providerId]?.defaultModel; const normalizedDefaultModel = typeof defaultModel === 'string' && defaultModel.trim().length > 0 diff --git a/test/features/codex-account/main/createCodexAccountFeature.test.ts b/test/features/codex-account/main/createCodexAccountFeature.test.ts index def10514..434a27f0 100644 --- a/test/features/codex-account/main/createCodexAccountFeature.test.ts +++ b/test/features/codex-account/main/createCodexAccountFeature.test.ts @@ -11,6 +11,7 @@ import type { const { apiKeyHasPreferredMock, apiKeyLookupMock, + binaryClearCacheMock, binaryResolveMock, detectLocalAccountStateMock, getCachedShellEnvMock, @@ -24,12 +25,15 @@ const { readAccountMock, readAccountSnapshotMock, readRateLimitsMock, + resolveInteractiveShellEnvBestEffortMock, } = vi.hoisted(() => ({ binaryResolveMock: vi.fn(), + binaryClearCacheMock: vi.fn(), apiKeyHasPreferredMock: vi.fn(), apiKeyLookupMock: vi.fn(), detectLocalAccountStateMock: vi.fn(), getCachedShellEnvMock: vi.fn(), + resolveInteractiveShellEnvBestEffortMock: vi.fn(), readAccountMock: vi.fn(), readAccountSnapshotMock: vi.fn(), readRateLimitsMock: vi.fn(), @@ -71,12 +75,14 @@ vi.mock('../../../../src/main/utils/shellEnv', async (importOriginal) => { return { ...actual, getCachedShellEnv: getCachedShellEnvMock, + resolveInteractiveShellEnvBestEffort: resolveInteractiveShellEnvBestEffortMock, }; }); vi.mock('../../../../src/main/services/infrastructure/codexAppServer', () => ({ CodexBinaryResolver: { resolve: binaryResolveMock, + clearCache: binaryClearCacheMock, }, CodexAppServerSessionFactory: class MockCodexAppServerSessionFactory {}, JsonRpcStdioClient: class MockJsonRpcStdioClient {}, @@ -231,6 +237,9 @@ describe('createCodexAccountFeature', () => { delete process.env.OPENAI_API_KEY; delete process.env.CODEX_API_KEY; binaryResolveMock.mockResolvedValue('/usr/local/bin/codex'); + binaryClearCacheMock.mockReset(); + resolveInteractiveShellEnvBestEffortMock.mockReset(); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({}); apiKeyHasPreferredMock.mockResolvedValue(false); apiKeyLookupMock.mockResolvedValue(null); detectLocalAccountStateMock.mockResolvedValue({ @@ -360,6 +369,69 @@ describe('createCodexAccountFeature', () => { } }); + it('retries Codex binary discovery after cold shell env resolves before publishing runtime-missing', async () => { + binaryResolveMock.mockResolvedValueOnce(null).mockResolvedValue('/usr/local/bin/codex'); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ + PATH: '/usr/local/bin:/usr/bin:/bin', + }); + readAccountMock.mockResolvedValue({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + const snapshot = await feature.refreshSnapshot(); + + expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 12_000, + fallbackEnv: process.env, + }) + ); + expect(binaryClearCacheMock).toHaveBeenCalledTimes(1); + expect(binaryResolveMock).toHaveBeenCalledTimes(2); + expect(snapshot.appServerState).toBe('healthy'); + expect(snapshot.launchReadinessState).toBe('ready_chatgpt'); + expect(snapshot.launchIssueMessage).toBeNull(); + } finally { + await feature.dispose(); + } + }); + + it('still reports runtime-missing after the cold binary retry cannot find Codex', async () => { + binaryResolveMock.mockResolvedValue(null); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ + PATH: '/usr/bin:/bin', + }); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + const snapshot = await feature.refreshSnapshot(); + + expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledTimes(1); + expect(binaryClearCacheMock).toHaveBeenCalledTimes(1); + expect(binaryResolveMock).toHaveBeenCalledTimes(2); + expect(snapshot.appServerState).toBe('runtime-missing'); + expect(snapshot.launchReadinessState).toBe('runtime_missing'); + expect(snapshot.launchIssueMessage).toContain('Codex CLI not found'); + } finally { + await feature.dispose(); + } + }); + it('reuses a fresh refresh snapshot when the request does not need stronger data', async () => { readAccountMock.mockResolvedValue({ account: createAccountResponse(),