diff --git a/landing/composables/usePageSeo.ts b/landing/composables/usePageSeo.ts index 61b0c852..e8ee947c 100644 --- a/landing/composables/usePageSeo.ts +++ b/landing/composables/usePageSeo.ts @@ -39,7 +39,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa const resolvedImage = computed(() => { if (options.image) return options.image; return { - url: "/og-image-agent-teams-v5.png", + url: "/og-image-agent-teams-v6.png", width: 1200, height: 630, type: "image/png", diff --git a/landing/error.vue b/landing/error.vue index 0afcf215..8fbb7c3c 100644 --- a/landing/error.vue +++ b/landing/error.vue @@ -9,7 +9,7 @@ const props = defineProps<{ const { t } = useI18n(); const config = useRuntimeConfig(); const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, ""); -const ogImage = `${siteUrl}/og-image-agent-teams-v5.png`; +const ogImage = `${siteUrl}/og-image-agent-teams-v6.png`; const statusCode = computed(() => props.error?.statusCode || 404); const isNotFound = computed(() => statusCode.value === 404); diff --git a/landing/nuxt.config.ts b/landing/nuxt.config.ts index a8bdd199..d6219bed 100644 --- a/landing/nuxt.config.ts +++ b/landing/nuxt.config.ts @@ -17,7 +17,7 @@ const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const defaultSeoTitle = "Agent Teams - AI Agent Orchestration for Developers"; const defaultSeoDescription = "Free, open-source desktop app for AI agent teams. Start with a free model with no auth, then connect Claude, Codex, or OpenCode when you need more models."; -const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v5.png`; +const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v6.png`; export default defineNuxtConfig({ compatibilityDate: "2026-01-19", diff --git a/landing/product-docs/.vitepress/config.ts b/landing/product-docs/.vitepress/config.ts index 47b17ff2..18b48fa5 100644 --- a/landing/product-docs/.vitepress/config.ts +++ b/landing/product-docs/.vitepress/config.ts @@ -32,6 +32,7 @@ const publicBaseUrl = const docsUrl = `${publicBaseUrl}docs/`; const downloadUrl = `${publicBaseUrl}download/`; const ruDownloadUrl = `${publicBaseUrl}ru/download/`; +const ogImageUrl = `${publicBaseUrl}og-image-agent-teams-v6.png`; const landingPublicDir = fileURLToPath(new URL("../../public", import.meta.url)); const rootGuide: DefaultTheme.SidebarItem[] = [ @@ -173,7 +174,7 @@ export default defineConfig({ ["meta", { property: "og:title", content: SITE_TITLE }], ["meta", { property: "og:description", content: SITE_DESCRIPTION }], ["meta", { property: "og:url", content: docsUrl }], - ["meta", { property: "og:image", content: `${publicBaseUrl}og-image.png` }], + ["meta", { property: "og:image", content: ogImageUrl }], ["meta", { property: "og:image:width", content: "1200" }], ["meta", { property: "og:image:height", content: "630" }], ["meta", { property: "og:site_name", content: "Agent Teams" }], @@ -181,7 +182,7 @@ export default defineConfig({ ["meta", { name: "twitter:card", content: "summary_large_image" }], ["meta", { name: "twitter:title", content: SITE_TITLE }], ["meta", { name: "twitter:description", content: SITE_DESCRIPTION }], - ["meta", { name: "twitter:image", content: `${publicBaseUrl}og-image.png` }], + ["meta", { name: "twitter:image", content: ogImageUrl }], [ "script", { type: "application/ld+json" }, diff --git a/landing/public/og-image-agent-teams-v6.png b/landing/public/og-image-agent-teams-v6.png new file mode 100644 index 00000000..52e586fc Binary files /dev/null and b/landing/public/og-image-agent-teams-v6.png differ diff --git a/landing/server/routes/sitemap.xml.ts b/landing/server/routes/sitemap.xml.ts index fdfa4aea..b8fd3e3c 100644 --- a/landing/server/routes/sitemap.xml.ts +++ b/landing/server/routes/sitemap.xml.ts @@ -15,8 +15,9 @@ export default defineEventHandler((event) => { const config = useRuntimeConfig(); const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, ""); const toSiteUrl = (path: string) => `${siteUrl}${path === "/" ? "/" : `/${path.replace(/^\/+/, "")}`}`; - const homeImagePaths = ["og-image.png", ...screenshots.map((screenshot) => screenshot.path)]; - const downloadImagePaths = ["og-image.png", "logo-192.png"]; + const ogImagePath = "og-image-agent-teams-v6.png"; + const homeImagePaths = [ogImagePath, ...screenshots.map((screenshot) => screenshot.path)]; + const downloadImagePaths = [ogImagePath, "logo-192.png"]; setHeader(event, "content-type", "application/xml; charset=utf-8"); diff --git a/scripts/dev-with-runtime.mjs b/scripts/dev-with-runtime.mjs index c20f751c..28f14d67 100644 --- a/scripts/dev-with-runtime.mjs +++ b/scripts/dev-with-runtime.mjs @@ -523,6 +523,7 @@ async function main() { const uiEnv = { ...process.env, + UV_THREADPOOL_SIZE: process.env.UV_THREADPOOL_SIZE?.trim() || '16', CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: resolvedRuntime.binaryPath, }; delete uiEnv.CLAUDE_CLI_PATH; diff --git a/src/main/services/runtime/teamRuntimeSettingsBundle.ts b/src/main/services/runtime/teamRuntimeSettingsBundle.ts index 8a01ba3c..4901f465 100644 --- a/src/main/services/runtime/teamRuntimeSettingsBundle.ts +++ b/src/main/services/runtime/teamRuntimeSettingsBundle.ts @@ -48,6 +48,7 @@ export function splitSettingsJsonArgs(args: string[]): SplitSettingsJsonArgsResu const parsed = parseJsonSettingsObject(value); if (parsed) { settingsFragments.push(parsed); + index += 1; continue; } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f20418c9..f5b199b1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2198,6 +2198,24 @@ function buildAnthropicCrossProviderDirectAuthEnvPatch( return envPatch; } +const CODEX_CROSS_PROVIDER_SAFE_ENV_KEYS = [ + 'CLAUDE_CODE_CODEX_BACKEND', + 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD', + 'CODEX_CLI_PATH', + 'CODEX_HOME', +] as const; + +function buildCodexCrossProviderSafeEnvPatch(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const envPatch: NodeJS.ProcessEnv = {}; + for (const key of CODEX_CROSS_PROVIDER_SAFE_ENV_KEYS) { + const value = env[key]?.trim(); + if (value) { + envPatch[key] = value; + } + } + return envPatch; +} + interface TeamRuntimeAuthContext { teamName?: string; authMaterialId?: string; @@ -2219,6 +2237,7 @@ interface TeamRuntimeLaunchArgsPlan { runtimeTurnSettledHookArgs: string[]; providerArgs: string[]; extraArgs: string[]; + inheritedProviderArgs: string[]; } type WorkspaceTrustProviderArgsResolver = (input: { @@ -4218,6 +4237,7 @@ export class TeamProvisioningService { launchIdentity?: ProviderModelLaunchIdentity | null; envResolution: ProvisioningEnvResolution; extraArgs?: string[]; + inheritedProviderArgs?: string[]; includeAnthropicHelper: boolean; contextLabel: string; }): Promise { @@ -4228,6 +4248,7 @@ export class TeamProvisioningService { : null; const rawProviderArgs = input.envResolution.providerArgs ?? []; const rawExtraArgs = input.extraArgs ?? []; + const rawInheritedProviderArgs = input.inheritedProviderArgs ?? []; if (!helper && resolvedProviderId !== 'anthropic') { return { @@ -4237,6 +4258,7 @@ export class TeamProvisioningService { await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId), providerArgs: rawProviderArgs, extraArgs: rawExtraArgs, + inheritedProviderArgs: rawInheritedProviderArgs, }; } @@ -4246,15 +4268,29 @@ export class TeamProvisioningService { ); const splitProviderArgs = splitSettingsJsonArgs(providerArgsWithoutHelper); const splitExtraArgs = splitSettingsJsonArgs(rawExtraArgs); + const splitInheritedArgs = splitSettingsJsonArgs(rawInheritedProviderArgs); + const shouldCoalesceInheritedSettings = splitInheritedArgs.settingsFragments.length > 0; if ( helper && (hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) || - hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs)) + hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs) || + hasPathBasedSettingsArgs(splitInheritedArgs.passthroughArgs)) ) { throw new Error( `${input.contextLabel}: app-managed Anthropic API-key helper cannot be combined with path-based --settings. Use inline JSON settings or remove the custom --settings path.` ); } + if ( + shouldCoalesceInheritedSettings && + !helper && + (hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) || + hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs) || + hasPathBasedSettingsArgs(splitInheritedArgs.passthroughArgs)) + ) { + throw new Error( + `${input.contextLabel}: mixed-provider launch cannot combine app-managed inherited settings with path-based --settings. Use inline JSON settings or remove the custom --settings path.` + ); + } const settingsBundle = await materializeTeamRuntimeSettingsBundle({ teamName: input.teamName, @@ -4264,6 +4300,7 @@ export class TeamProvisioningService { await this.buildRuntimeTurnSettledHookSettingsObject(resolvedProviderId), ...splitProviderArgs.settingsFragments, ...splitExtraArgs.settingsFragments, + ...splitInheritedArgs.settingsFragments, ], anthropicHelper: helper, settingsDirectory: helper ? null : buildRuntimeSettingsTempDirectory(input.teamName), @@ -4275,6 +4312,7 @@ export class TeamProvisioningService { runtimeTurnSettledHookArgs: [], providerArgs: splitProviderArgs.passthroughArgs, extraArgs: splitExtraArgs.passthroughArgs, + inheritedProviderArgs: splitInheritedArgs.passthroughArgs, }; } @@ -20181,6 +20219,7 @@ export class TeamProvisioningService { launchIdentity, envResolution: { ...provisioningEnv, providerArgs: providerArgsForLaunch }, extraArgs: extraCliArgs, + inheritedProviderArgs: crossProviderMemberArgsForLaunch.args, includeAnthropicHelper: resolvedProviderId === 'anthropic', contextLabel: 'Team create launch', }); @@ -20216,7 +20255,7 @@ export class TeamProvisioningService { ...runtimeArgsPlan.extraArgs, ...runtimeArgsPlan.providerArgs, ...runtimeArgsPlan.settingsArgs, - ...crossProviderMemberArgsForLaunch.args, + ...runtimeArgsPlan.inheritedProviderArgs, ]); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, @@ -21509,6 +21548,7 @@ export class TeamProvisioningService { launchIdentity, envResolution: { ...provisioningEnv, providerArgs: providerArgsForLaunch }, extraArgs: extraCliArgs, + inheritedProviderArgs: crossProviderMemberArgsForLaunch.args, includeAnthropicHelper: resolvedProviderId === 'anthropic', contextLabel: 'Team launch', }); @@ -21533,7 +21573,7 @@ export class TeamProvisioningService { // Without this, a codex teammate spawned from an anthropic lead has no way to learn // about the required forced_login_method (chatgpt/api) and fails to start. emitProvisioningCheckpoint(run, 'Resolving cross-provider member launch args'); - launchArgs.push(...crossProviderMemberArgsForLaunch.args); + launchArgs.push(...runtimeArgsPlan.inheritedProviderArgs); const finalLaunchArgs = mergeJsonSettingsArgs(launchArgs); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, @@ -35178,6 +35218,9 @@ export class TeamProvisioningService { args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId))); const providerArgs = env.providerArgs ?? []; providerArgsByProvider.set(providerId, providerArgs); + if (providerId === 'codex') { + Object.assign(envPatch, buildCodexCrossProviderSafeEnvPatch(env.env)); + } if (env.anthropicApiKeyHelper) { usesAnthropicApiKeyHelper = true; Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch); diff --git a/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts b/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts index 51ab1e90..a8f46aac 100644 --- a/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts +++ b/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts @@ -21,6 +21,8 @@ const DIRECT_TMUX_RESTART_ENV_KEYS = [ 'CLAUDE_CODE_ENTRY_PROVIDER', 'CLAUDE_CODE_GEMINI_BACKEND', 'CLAUDE_CODE_CODEX_BACKEND', + 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD', + 'CODEX_CLI_PATH', 'CODEX_HOME', CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV, CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, diff --git a/test/main/services/runtime/teamRuntimeSettingsBundle.test.ts b/test/main/services/runtime/teamRuntimeSettingsBundle.test.ts index c1a63348..995dae60 100644 --- a/test/main/services/runtime/teamRuntimeSettingsBundle.test.ts +++ b/test/main/services/runtime/teamRuntimeSettingsBundle.test.ts @@ -1,12 +1,13 @@ // @vitest-environment node +import { + materializeTeamRuntimeSettingsBundle, + splitSettingsJsonArgs, +} from '@main/services/runtime/teamRuntimeSettingsBundle'; import { mkdtemp, readFile, rm } from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; - import { afterEach, describe, expect, it } from 'vitest'; -import { materializeTeamRuntimeSettingsBundle } from '@main/services/runtime/teamRuntimeSettingsBundle'; - describe('teamRuntimeSettingsBundle', () => { const tempRoots: string[] = []; @@ -71,4 +72,17 @@ describe('teamRuntimeSettingsBundle', () => { expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); expect(settings.hooks.Stop).toHaveLength(1); }); + + it('splits equals-style JSON settings without dropping later args', () => { + expect( + splitSettingsJsonArgs([ + '--settings={"codex":{"forced_login_method":"chatgpt"}}', + '--model', + 'gpt-5.5', + ]) + ).toEqual({ + settingsFragments: [{ codex: { forced_login_method: 'chatgpt' } }], + passthroughArgs: ['--model', 'gpt-5.5'], + }); + }); }); diff --git a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts index 76a42071..f1a9a952 100644 --- a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts +++ b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts @@ -303,6 +303,70 @@ liveDescribe('Mixed provider team launch live e2e', () => { ([laneId, lane]) => lane.state === 'active' && laneId === 'secondary:opencode:oscar' ) ).toBe(true); + + await cleanupMixedProviderSmokeTeam(harness, teamName); + + const relaunchProgressEvents: TeamProvisioningProgress[] = []; + await harness.svc.launchTeam( + { + teamName, + cwd: projectPath, + providerId: 'anthropic', + model: anthropicModel, + skipPermissions: true, + clearContext: true, + }, + (progress) => { + relaunchProgressEvents.push(progress); + } + ); + + await waitUntil(async () => { + const last = relaunchProgressEvents.at(-1); + if (last?.state === 'failed') { + throw new Error(formatProgressDump(relaunchProgressEvents)); + } + return last?.state === 'ready'; + }, 360_000); + + await waitUntilWithDiagnostics(async () => { + const status = await harness!.svc.getMemberSpawnStatuses(teamName!); + if (status.teamLaunchState === 'partial_failure') { + throw new Error( + await formatMixedLaunchDiagnostics(harness!, teamName!, relaunchProgressEvents) + ); + } + for (const memberName of ['alice', 'cody', 'oscar'] as const) { + const member = status.statuses[memberName]; + if ( + member?.status !== 'online' || + member.launchState !== 'confirmed_alive' || + member.bootstrapConfirmed !== true + ) { + return false; + } + } + return true; + }, 180_000, () => formatMixedLaunchDiagnostics(harness!, teamName!, relaunchProgressEvents)); + + await waitUntilWithDiagnostics(async () => { + const snapshot = await harness!.svc.getTeamAgentRuntimeSnapshot(teamName!); + return ( + snapshot.members.alice?.providerId === 'anthropic' && + snapshot.members.alice.alive === true && + snapshot.members.cody?.providerId === 'codex' && + snapshot.members.cody.alive === true && + snapshot.members.oscar?.providerId === 'opencode' && + snapshot.members.oscar.alive === true + ); + }, 180_000, () => formatMixedLaunchDiagnostics(harness!, teamName!, relaunchProgressEvents)); + + const relaunchedLaneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); + expect( + Object.entries(relaunchedLaneIndex.lanes).some( + ([laneId, lane]) => lane.state === 'active' && laneId === 'secondary:opencode:oscar' + ) + ).toBe(true); }, 480_000 ); diff --git a/test/main/services/team/TeamProvisioningDirectRestart.test.ts b/test/main/services/team/TeamProvisioningDirectRestart.test.ts index b1fee3a8..71b5102b 100644 --- a/test/main/services/team/TeamProvisioningDirectRestart.test.ts +++ b/test/main/services/team/TeamProvisioningDirectRestart.test.ts @@ -57,9 +57,11 @@ describe('TeamProvisioningDirectRestart', () => { const assignments = buildDirectTmuxRestartEnvAssignments( { CODEX_HOME: '/tmp/codex home', + CODEX_CLI_PATH: '/opt/codex/bin/codex', CLAUDE_CODE_USE_GEMINI: '1', CLAUDE_CODE_ENTRY_PROVIDER: 'gemini', CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', }, 'codex' ); @@ -67,9 +69,11 @@ describe('TeamProvisioningDirectRestart', () => { expect(assignments).toContain("CLAUDECODE='1'"); expect(assignments).toContain("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS='1'"); expect(assignments).toContain("CODEX_HOME='/tmp/codex home'"); + expect(assignments).toContain("CODEX_CLI_PATH='/opt/codex/bin/codex'"); expect(assignments).toContain("CLAUDE_CODE_USE_GEMINI=''"); expect(assignments).toContain("CLAUDE_CODE_ENTRY_PROVIDER='codex'"); expect(assignments).toContain("CLAUDE_CODE_CODEX_BACKEND='codex-native'"); + expect(assignments).toContain("CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD='chatgpt'"); expect(assignments).toContain("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST='1'"); }); diff --git a/test/main/services/team/TeamProvisioningMemberMcpConfig.safe-e2e.test.ts b/test/main/services/team/TeamProvisioningMemberMcpConfig.safe-e2e.test.ts index 487871a9..794080a9 100644 --- a/test/main/services/team/TeamProvisioningMemberMcpConfig.safe-e2e.test.ts +++ b/test/main/services/team/TeamProvisioningMemberMcpConfig.safe-e2e.test.ts @@ -530,6 +530,7 @@ describe('TeamProvisioningService member MCP config safe e2e', () => { providerArgs: string[]; settingsArgs: string[]; extraArgs: string[]; + inheritedProviderArgs: string[]; }>; } ).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({ @@ -538,6 +539,7 @@ describe('TeamProvisioningService member MCP config safe e2e', () => { providerArgs: [], settingsArgs: [], extraArgs: [], + inheritedProviderArgs: [], })); ( svc as unknown as { updateDirectTmuxRestartMemberConfig: () => Promise } diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index c9f6fc91..b0204629 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -15537,6 +15537,7 @@ describe('TeamProvisioningService', () => { providerArgs: [], settingsArgs: [], extraArgs: [], + inheritedProviderArgs: [], })); (svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({})); (svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {}); @@ -15635,6 +15636,7 @@ describe('TeamProvisioningService', () => { providerArgs: [], settingsArgs: [], extraArgs: [], + inheritedProviderArgs: [], })); (svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({})); (svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {}); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 2cfdbd9d..83ae6dc9 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -1,3 +1,4 @@ +import { buildCodexWorkspaceTrustSettingsArgs } from '@features/workspace-trust/core/domain'; import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { spawn } from 'child_process'; @@ -555,6 +556,44 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(result.args).toContain('--anthropic-safe-passthrough'); }); + it('passes only non-secret Codex runtime env to non-Codex leads for cross-provider teammates', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + CODEX_CLI_PATH: '/Users/tester/.local/bin/codex', + CODEX_HOME: '/Users/tester/.codex', + OPENAI_API_KEY: 'sk-openai-should-not-leak', + CODEX_API_KEY: 'sk-codex-should-not-leak', + GEMINI_API_KEY: 'gemini-should-not-leak', + ANTHROPIC_API_KEY: 'sk-ant-should-not-leak', + ANTHROPIC_AUTH_TOKEN: 'ant-token-should-not-leak', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + }); + + const result = await (svc as any).buildCrossProviderMemberArgs( + 'anthropic', + [{ name: 'jack', providerId: 'codex', model: 'gpt-5.4' }], + { teamRuntimeAuth: { teamName: 'mixed-team', authMaterialId: 'run-1' } } + ); + + expect(result.envPatch).toMatchObject({ + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + CODEX_CLI_PATH: '/Users/tester/.local/bin/codex', + CODEX_HOME: '/Users/tester/.codex', + }); + expect(result.envPatch.OPENAI_API_KEY).toBeUndefined(); + expect(result.envPatch.CODEX_API_KEY).toBeUndefined(); + expect(result.envPatch.GEMINI_API_KEY).toBeUndefined(); + expect(result.envPatch.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.envPatch.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + }); + it('passes Anthropic-compatible bearer env to non-Anthropic leads without injecting ANTHROPIC_API_KEY', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ @@ -3511,6 +3550,427 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(settings.hooks.Stop[0].hooks[0].command).toBe('/bin/true # test-hook'); }); + it('coalesces inherited cross-provider JSON settings into the Anthropic runtime settings file', async () => { + const svc = new TeamProvisioningService(); + + const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-codex-inherited-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { providerArgs: [] }, + extraArgs: [], + inheritedProviderArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }); + + expect(result.settingsArgs[0]).toBe('--settings'); + expect(result.inheritedProviderArgs).toEqual([]); + const settings = JSON.parse(fs.readFileSync(result.settingsArgs[1], 'utf8')); + expect(settings.codex.forced_login_method).toBe('chatgpt'); + }); + + it('merges provider, extra, and inherited JSON settings in launch precedence order', async () => { + const svc = new TeamProvisioningService(); + + const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-codex-merged-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { + providerArgs: [ + '--settings', + '{"codex":{"forced_login_method":"api","nested":{"provider":true}}}', + '--provider-passthrough', + ], + }, + extraArgs: [ + '--settings={"codex":{"nested":{"extra":true}}}', + '--extra-passthrough', + ], + inheritedProviderArgs: [ + '--settings', + '{"codex":{"forced_login_method":"chatgpt","nested":{"inherited":true}}}', + '--inherited-passthrough', + ], + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }); + + expect(result.providerArgs).toEqual(['--provider-passthrough']); + expect(result.extraArgs).toEqual(['--extra-passthrough']); + expect(result.inheritedProviderArgs).toEqual(['--inherited-passthrough']); + const settings = JSON.parse(fs.readFileSync(result.settingsArgs[1], 'utf8')); + expect(settings.codex).toMatchObject({ + forced_login_method: 'chatgpt', + nested: { + provider: true, + extra: true, + inherited: true, + }, + }); + }); + + it('coalesces equals-style inherited settings while preserving inherited passthrough args', async () => { + const svc = new TeamProvisioningService(); + + const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-codex-equals-inherited-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { providerArgs: [] }, + extraArgs: [], + inheritedProviderArgs: [ + '--settings={"codex":{"forced_login_method":"chatgpt"}}', + '--safe-inherited-flag', + 'value', + ], + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }); + + expect(result.inheritedProviderArgs).toEqual(['--safe-inherited-flag', 'value']); + const settings = JSON.parse(fs.readFileSync(result.settingsArgs[1], 'utf8')); + expect(settings.codex.forced_login_method).toBe('chatgpt'); + }); + + it('leaves inherited settings untouched for non-Anthropic lead providers', async () => { + const svc = new TeamProvisioningService(); + const inheritedProviderArgs = ['--settings', '{"anthropic":{"example":true}}']; + + const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'codex-lead-anthropic-inherited-settings-team', + providerId: 'codex', + launchIdentity: null, + envResolution: { providerArgs: [] }, + extraArgs: [], + inheritedProviderArgs, + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }); + + expect(result.settingsArgs).toEqual([]); + expect(result.inheritedProviderArgs).toEqual(inheritedProviderArgs); + }); + + it('coalesces inherited JSON settings into Anthropic helper settings without keeping helper path args', async () => { + const svc = new TeamProvisioningService(); + const helperDir = path.join(tempRoot, 'anthropic-helper'); + const helperSettingsPath = path.join(helperDir, 'settings.json'); + + const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-helper-codex-inherited-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { + providerArgs: ['--settings', helperSettingsPath], + anthropicApiKeyHelper: { + directory: helperDir, + helperPath: path.join(helperDir, 'helper.sh'), + keyPath: path.join(helperDir, 'key'), + settingsPath: helperSettingsPath, + settingsObject: { apiKeyHelper: `'${path.join(helperDir, 'helper.sh')}'` }, + settingsArgs: ['--settings', helperSettingsPath], + envPatch: {}, + }, + }, + extraArgs: [], + inheritedProviderArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + includeAnthropicHelper: true, + contextLabel: 'Team launch', + }); + + expect(result.providerArgs).toEqual([]); + expect(result.inheritedProviderArgs).toEqual([]); + expect(result.settingsArgs[0]).toBe('--settings'); + expect(result.settingsArgs[1]).toContain(helperDir); + expect(result.settingsArgs[1]).not.toBe(helperSettingsPath); + const settings = JSON.parse(fs.readFileSync(result.settingsArgs[1], 'utf8')); + expect(settings.apiKeyHelper).toBe(`'${path.join(helperDir, 'helper.sh')}'`); + expect(settings.codex.forced_login_method).toBe('chatgpt'); + }); + + it('keeps Anthropic helper credentials authoritative over inherited helper-like settings', async () => { + const svc = new TeamProvisioningService(); + const helperDir = path.join(tempRoot, 'anthropic-helper-precedence'); + const helperSettingsPath = path.join(helperDir, 'settings.json'); + const helperPath = path.join(helperDir, 'helper.sh'); + + const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-helper-precedence-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { + providerArgs: ['--settings', helperSettingsPath], + anthropicApiKeyHelper: { + directory: helperDir, + helperPath, + keyPath: path.join(helperDir, 'key'), + settingsPath: helperSettingsPath, + settingsObject: { apiKeyHelper: `'${helperPath}'` }, + settingsArgs: ['--settings', helperSettingsPath], + envPatch: {}, + }, + }, + extraArgs: [], + inheritedProviderArgs: [ + '--settings', + '{"apiKeyHelper":"\\"/tmp/bad-helper.sh\\"","codex":{"forced_login_method":"chatgpt"}}', + ], + includeAnthropicHelper: true, + contextLabel: 'Team launch', + }); + + const settings = JSON.parse(fs.readFileSync(result.settingsArgs[1], 'utf8')); + expect(settings.apiKeyHelper).toBe(`'${helperPath}'`); + expect(JSON.stringify(settings)).not.toContain('/tmp/bad-helper.sh'); + expect(settings.codex.forced_login_method).toBe('chatgpt'); + }); + + it('coalesces multiple non-primary provider settings without leaking provider secrets into env patch', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockImplementation( + (providerId: unknown) => { + const resolvedProviderId = typeof providerId === 'string' ? providerId : undefined; + if (resolvedProviderId === 'codex') { + return Promise.resolve({ + env: { + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + CODEX_CLI_PATH: '/opt/codex', + CODEX_HOME: '/Users/tester/.codex', + CODEX_API_KEY: 'sk-codex-should-not-leak', + OPENAI_API_KEY: 'sk-openai-should-not-leak', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + providerArgs: [ + '--settings', + '{"codex":{"forced_login_method":"chatgpt"}}', + '--codex-passthrough', + ], + }); + } + if (resolvedProviderId === 'gemini') { + return Promise.resolve({ + env: { + GEMINI_API_KEY: 'gemini-should-not-leak', + GOOGLE_APPLICATION_CREDENTIALS: '/tmp/gcp-creds.json', + }, + authSource: 'gemini_api_key', + geminiRuntimeAuth: null, + providerArgs: [ + '--settings', + '{"gemini":{"auth_refresh":"gcp"}}', + '--gemini-passthrough', + ], + }); + } + return Promise.resolve({ + env: {}, + authSource: 'none', + geminiRuntimeAuth: null, + providerArgs: [], + }); + } + ); + + const crossProvider = await (svc as any).buildCrossProviderMemberArgs( + 'anthropic', + [ + { name: 'cody', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'gina', providerId: 'gemini', model: 'gemini-2.5-pro' }, + ], + { teamRuntimeAuth: { teamName: 'mixed-team', authMaterialId: 'run-1' } } + ); + const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-codex-gemini-inherited-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { providerArgs: [] }, + extraArgs: [], + inheritedProviderArgs: crossProvider.args, + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }); + + expect(crossProvider.envPatch).toMatchObject({ + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + CODEX_CLI_PATH: '/opt/codex', + CODEX_HOME: '/Users/tester/.codex', + }); + expect(crossProvider.envPatch.CODEX_API_KEY).toBeUndefined(); + expect(crossProvider.envPatch.OPENAI_API_KEY).toBeUndefined(); + expect(crossProvider.envPatch.GEMINI_API_KEY).toBeUndefined(); + expect(crossProvider.envPatch.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined(); + expect(result.inheritedProviderArgs).toEqual(['--codex-passthrough', '--gemini-passthrough']); + const settings = JSON.parse(fs.readFileSync(result.settingsArgs[1], 'utf8')); + expect(settings.codex.forced_login_method).toBe('chatgpt'); + expect(settings.gemini.auth_refresh).toBe('gcp'); + }); + + it('coalesces workspace trust patches after inherited cross-provider args are patched', async () => { + const svc = new TeamProvisioningService(); + const trustOverride = 'projects."/repo".trust_level="trusted"'; + const inheritedProviderArgs = (svc as any).applyWorkspaceTrustArgPatches({ + args: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + patches: [ + { + id: 'codex-trust', + owner: 'workspace-trust', + targetProvider: 'codex', + targetSurface: 'cross_provider_member_args', + dialect: 'claude-codex-runtime-settings', + args: buildCodexWorkspaceTrustSettingsArgs([trustOverride]), + dedupeKey: 'codex-trust', + sourceWorkspaceIds: ['workspace-1'], + reason: 'Codex native trust is carried through sibling runtime settings.', + }, + ], + targetProvider: 'codex', + targetSurface: 'cross_provider_member_args', + }); + + const result = await (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-codex-workspace-trust-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { providerArgs: [] }, + extraArgs: [], + inheritedProviderArgs, + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }); + + expect(result.inheritedProviderArgs).toEqual([]); + const settings = JSON.parse(fs.readFileSync(result.settingsArgs[1], 'utf8')); + expect(settings.codex).toMatchObject({ + forced_login_method: 'chatgpt', + agent_teams_workspace_trust: { + config_overrides: [trustOverride], + }, + }); + }); + + it('rejects path-based settings when inherited mixed-provider settings must be coalesced', async () => { + const svc = new TeamProvisioningService(); + + await expect( + (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-codex-path-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { providerArgs: [] }, + extraArgs: ['--settings', '/tmp/custom-settings.json'], + inheritedProviderArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }) + ).rejects.toThrow('mixed-provider launch cannot combine app-managed inherited settings'); + }); + + it('rejects provider path-based settings when inherited mixed-provider settings must be coalesced', async () => { + const svc = new TeamProvisioningService(); + + await expect( + (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-codex-provider-path-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { providerArgs: ['--settings', '/tmp/provider-settings.json'] }, + extraArgs: [], + inheritedProviderArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }) + ).rejects.toThrow('mixed-provider launch cannot combine app-managed inherited settings'); + }); + + it('rejects inherited path-based settings alongside inherited mixed-provider JSON settings', async () => { + const svc = new TeamProvisioningService(); + + await expect( + (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-codex-inherited-path-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { providerArgs: [] }, + extraArgs: [], + inheritedProviderArgs: [ + '--settings', + '{"codex":{"forced_login_method":"chatgpt"}}', + '--settings', + '/tmp/inherited-custom-settings.json', + ], + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }) + ).rejects.toThrow('mixed-provider launch cannot combine app-managed inherited settings'); + }); + + it('rejects dangling path-based settings when inherited mixed-provider settings must be coalesced', async () => { + const svc = new TeamProvisioningService(); + + await expect( + (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-codex-dangling-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { providerArgs: [] }, + extraArgs: ['--settings'], + inheritedProviderArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }) + ).rejects.toThrow('mixed-provider launch cannot combine app-managed inherited settings'); + }); + + it('rejects equals-style path settings when inherited mixed-provider settings must be coalesced', async () => { + const svc = new TeamProvisioningService(); + + await expect( + (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-codex-equals-path-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { providerArgs: ['--settings=/tmp/provider-settings.json'] }, + extraArgs: [], + inheritedProviderArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + includeAnthropicHelper: false, + contextLabel: 'Team launch', + }) + ).rejects.toThrow('mixed-provider launch cannot combine app-managed inherited settings'); + }); + + it('rejects inherited path-based settings when Anthropic helper settings are app-managed', async () => { + const svc = new TeamProvisioningService(); + + await expect( + (svc as any).buildTeamRuntimeLaunchArgsPlan({ + teamName: 'anthropic-helper-inherited-path-settings-team', + providerId: 'anthropic', + launchIdentity: null, + envResolution: { + providerArgs: [], + anthropicApiKeyHelper: { + directory: '/tmp/anthropic-helper', + helperPath: '/tmp/anthropic-helper/helper.sh', + keyPath: '/tmp/anthropic-helper/key', + settingsPath: '/tmp/anthropic-helper/settings.json', + settingsObject: { apiKeyHelper: "'/tmp/anthropic-helper/helper.sh'" }, + settingsArgs: ['--settings', '/tmp/anthropic-helper/settings.json'], + envPatch: {}, + }, + }, + extraArgs: [], + inheritedProviderArgs: ['--settings', '/tmp/custom-settings.json'], + includeAnthropicHelper: true, + contextLabel: 'Team launch', + }) + ).rejects.toThrow('app-managed Anthropic API-key helper cannot be combined'); + }); + it('adds Codex turn-settled env when Codex is only a secondary member provider', async () => { const svc = new TeamProvisioningService(); svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) => diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 2a93ec19..8f5d1751 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -1,12 +1,10 @@ +import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import { EventEmitter } from 'events'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; - const hoisted = vi.hoisted(() => ({ paths: { claudeRoot: '', @@ -109,12 +107,13 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { }; }); +import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime/TeamRuntimeAdapter'; import { buildAddMemberSpawnMessage, buildRestartMemberSpawnMessage, TeamProvisioningService, } from '@main/services/team/TeamProvisioningService'; -import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli, spawnCli } from '@main/utils/childProcess'; import { setAppDataBasePath } from '@main/utils/pathDecoder'; @@ -166,6 +165,41 @@ function extractBootstrapSpec(callIndex = 0): { }; } +function readRuntimeSettingsFromLaunchArgs(callIndex = 0): Record { + const args = vi.mocked(spawnCli).mock.calls[callIndex]?.[1] as string[] | undefined; + const settingsFlagIndex = args?.indexOf('--settings') ?? -1; + const settingsValue = settingsFlagIndex >= 0 ? args?.[settingsFlagIndex + 1] : null; + if (!settingsValue) { + throw new Error('Failed to extract runtime settings from spawn args'); + } + if (settingsValue.trim().startsWith('{')) { + return JSON.parse(settingsValue) as Record; + } + return JSON.parse(fs.readFileSync(settingsValue, 'utf8')) as Record; +} + +function registerNoopOpenCodeRuntimeAdapter(svc: TeamProvisioningService): void { + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(async (input: { model?: string }) => ({ + ok: true, + providerId: 'opencode', + modelId: input.model ?? null, + diagnostics: [], + warnings: [], + })), + launch: vi.fn(async () => { + throw new Error('OpenCode side lane launch should not run in this test'); + }), + reconcile: vi.fn(async () => ({ members: {}, warnings: [], diagnostics: [] })), + stop: vi.fn(async () => ({ stopped: true, members: {}, warnings: [], diagnostics: [] })), + } as any, + ]) + ); +} + describe('TeamProvisioningService prompt content (solo mode discipline)', () => { beforeEach(() => { vi.clearAllMocks(); @@ -477,6 +511,65 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + it('coalesces codex cross-provider launch overrides into createTeam Anthropic runtime settings', async () => { + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + const { child } = createFakeChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(); + registerNoopOpenCodeRuntimeAdapter(svc); + (svc as any).buildProvisioningEnv = vi.fn(async (providerId: string | undefined) => + providerId === 'codex' + ? { + env: { + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + } + : { + env: {}, + authSource: 'none', + geminiRuntimeAuth: null, + providerArgs: [], + } + ); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async () => false); + + const { runId } = await svc.createTeam( + { + teamName: 'anthropic-codex-create-team', + cwd: process.cwd(), + members: [ + { name: 'alice', role: 'developer', providerId: 'codex' }, + { + name: 'bob', + role: 'reviewer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }, + ], + providerId: 'anthropic', + }, + () => {} + ); + + const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + expect(launchArgs).not.toContain('{"codex":{"forced_login_method":"chatgpt"}}'); + expect(launchArgs.join(' ')).not.toContain('minimax-m2.5-free'); + expect(extractBootstrapSpec().members).toEqual([ + expect.objectContaining({ name: 'alice', provider: 'codex' }), + ]); + const settings = readRuntimeSettingsFromLaunchArgs(); + expect((settings.codex as Record).forced_login_method).toBe('chatgpt'); + + await svc.cancelProvisioning(runId); + }); + it('blocks Codex xhigh launch effort until runtime exposes reasoning config passthrough', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex'); vi.mocked(spawnCli).mockReset(); @@ -1065,4 +1158,111 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + + it('coalesces codex cross-provider launch overrides into launchTeam Anthropic runtime settings', async () => { + const teamName = 'anthropic-codex-launch-team'; + const teamDir = path.join(tempTeamsBase, teamName); + fs.mkdirSync(teamDir, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: teamName, + members: [ + { name: 'team-lead', agentType: 'team-lead', providerId: 'anthropic' }, + { + name: 'alice', + agentType: 'teammate', + role: 'developer', + providerId: 'codex', + model: 'gpt-5.4', + }, + { + name: 'bob', + agentType: 'teammate', + role: 'reviewer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }, + ], + }), + 'utf8' + ); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + const { child } = createFakeChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(); + registerNoopOpenCodeRuntimeAdapter(svc); + (svc as any).buildProvisioningEnv = vi.fn(async (providerId: string | undefined) => + providerId === 'codex' + ? { + env: { + CLAUDE_CODE_CODEX_BACKEND: 'codex-native', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + } + : { + env: {}, + authSource: 'none', + geminiRuntimeAuth: null, + providerArgs: [], + } + ); + (svc as any).resolveProviderDefaultModel = vi.fn(async (providerId: string | undefined) => + providerId === 'codex' ? 'gpt-5.4' : 'opus' + ); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [ + { + name: 'alice', + role: 'developer', + providerId: 'codex', + model: 'gpt-5.4', + isolation: 'worktree', + }, + { + name: 'bob', + role: 'reviewer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + isolation: 'worktree', + }, + ], + source: 'config-fallback', + warning: undefined, + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async () => false); + (svc as any).startFilesystemMonitor = vi.fn(); + + const { runId } = await svc.launchTeam( + { + teamName, + cwd: process.cwd(), + providerId: 'anthropic', + clearContext: true, + } as any, + () => {} + ); + + const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + expect(launchArgs).not.toContain('{"codex":{"forced_login_method":"chatgpt"}}'); + expect(launchArgs.join(' ')).not.toContain('minimax-m2.5-free'); + expect(extractBootstrapSpec().members).toEqual([ + expect.objectContaining({ name: 'alice', provider: 'codex' }), + ]); + const settings = readRuntimeSettingsFromLaunchArgs(); + expect((settings.codex as Record).forced_login_method).toBe('chatgpt'); + + await svc.cancelProvisioning(runId); + }); });