fix(team): preserve mixed provider runtime settings

This commit is contained in:
777genius 2026-05-27 18:22:10 +03:00
parent e363394c72
commit 7cc1a59bbc
17 changed files with 812 additions and 17 deletions

View file

@ -39,7 +39,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
const resolvedImage = computed<PageSeoImage>(() => {
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",

View file

@ -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);

View file

@ -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",

View file

@ -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" },

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

View file

@ -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");

View file

@ -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;

View file

@ -48,6 +48,7 @@ export function splitSettingsJsonArgs(args: string[]): SplitSettingsJsonArgsResu
const parsed = parseJsonSettingsObject(value);
if (parsed) {
settingsFragments.push(parsed);
index += 1;
continue;
}
}

View file

@ -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<TeamRuntimeLaunchArgsPlan> {
@ -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);

View file

@ -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,

View file

@ -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'],
});
});
});

View file

@ -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
);

View file

@ -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'");
});

View file

@ -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<void> }

View file

@ -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 () => {});

View file

@ -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 }) =>

View file

@ -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<string, unknown> {
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<string, unknown>;
}
return JSON.parse(fs.readFileSync(settingsValue, 'utf8')) as Record<string, unknown>;
}
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<string, unknown>).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<string, unknown>).forced_login_method).toBe('chatgpt');
await svc.cancelProvisioning(runId);
});
});