fix(team): preserve mixed provider runtime settings
This commit is contained in:
parent
e363394c72
commit
7cc1a59bbc
17 changed files with 812 additions and 17 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
BIN
landing/public/og-image-agent-teams-v6.png
Normal file
BIN
landing/public/og-image-agent-teams-v6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 673 KiB |
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export function splitSettingsJsonArgs(args: string[]): SplitSettingsJsonArgsResu
|
|||
const parsed = parseJsonSettingsObject(value);
|
||||
if (parsed) {
|
||||
settingsFragments.push(parsed);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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> }
|
||||
|
|
|
|||
|
|
@ -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 () => {});
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue