feat(runtime): enable codex-native limited internal unlock

This commit is contained in:
777genius 2026-04-19 20:49:29 +03:00
parent e83e3cbcc9
commit b5dfa14868
22 changed files with 543 additions and 53 deletions

View file

@ -100,6 +100,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini');
})();
const prompt = assertOptionalString(payload.prompt, 'prompt');
const providerBackendId = assertOptionalString(payload.providerBackendId, 'providerBackendId');
const model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort);
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
@ -111,6 +112,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
teamName,
cwd: assertAbsoluteCwd(payload.cwd),
providerId,
...(providerBackendId && {
providerBackendId,
}),
...(prompt && {
prompt,
}),

View file

@ -1126,6 +1126,25 @@ function parseOptionalMemberProviderId(
return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' };
}
function parseOptionalProviderBackendId(
value: unknown
): { valid: true; value: string | undefined } | { valid: false; error: string } {
if (value === undefined || value === null || value === '') {
return { valid: true, value: undefined };
}
if (typeof value !== 'string') {
return { valid: false, error: 'providerBackendId must be a string' };
}
const trimmed = value.trim();
if (!trimmed) {
return { valid: true, value: undefined };
}
if (trimmed.length > 64) {
return { valid: false, error: 'providerBackendId too long (max 64)' };
}
return { valid: true, value: trimmed };
}
function parseOptionalMemberEffort(
value: unknown
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
@ -1219,6 +1238,10 @@ async function validateProvisioningRequest(
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
return { valid: false, error: 'prompt must be a string' };
}
const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId);
if (!providerBackendValidation.valid) {
return { valid: false, error: providerBackendValidation.error };
}
try {
await fs.promises.mkdir(cwd, { recursive: true });
@ -1276,6 +1299,7 @@ async function validateProvisioningRequest(
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic',
providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
skipPermissions:
@ -1385,6 +1409,10 @@ async function handleLaunchTeam(
if (payload.model !== undefined && typeof payload.model !== 'string') {
return { success: false, error: 'model must be a string' };
}
const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId);
if (!providerBackendValidation.valid) {
return { success: false, error: providerBackendValidation.error };
}
// Detect draft team: team.meta.json exists but config.json doesn't.
// This happens when user created team config without launching (launchTeam=false),
@ -1403,7 +1431,8 @@ async function handleLaunchTeam(
if (isDraft) {
const meta = await teamMetaStore.getMeta(tn);
const membersStore = new TeamMembersMetaStore();
const members = await membersStore.getMembers(tn);
const membersMeta = await membersStore.getMeta(tn);
const members = membersMeta?.members ?? [];
const createRequest: TeamCreateRequest = {
teamName: tn,
@ -1422,6 +1451,10 @@ async function handleLaunchTeam(
: meta?.providerId === 'gemini'
? 'gemini'
: 'anthropic',
providerBackendId:
providerBackendValidation.value ??
meta?.providerBackendId ??
membersMeta?.providerBackendId,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
@ -1468,6 +1501,7 @@ async function handleLaunchTeam(
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic',
providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
clearContext: payload.clearContext === true ? true : undefined,
@ -2552,6 +2586,10 @@ async function handleCreateConfig(
return { success: false, error: 'cwd must be an absolute path' };
}
}
const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId);
if (!providerBackendValidation.valid) {
return { success: false, error: providerBackendValidation.error };
}
const seenNames = new Set<string>();
const members: TeamCreateConfigRequest['members'] = [];
@ -2609,6 +2647,7 @@ async function handleCreateConfig(
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
members,
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
providerBackendId: providerBackendValidation.value,
})
);
}
@ -3884,7 +3923,8 @@ async function handleGetSavedRequest(
}
const membersStore = new TeamMembersMetaStore();
const members = await membersStore.getMembers(tn);
const membersMeta = await membersStore.getMeta(tn);
const members = membersMeta?.members ?? [];
return {
success: true,
@ -3896,6 +3936,7 @@ async function handleGetSavedRequest(
cwd: meta.cwd,
prompt: meta.prompt,
providerId: meta.providerId ?? 'anthropic',
providerBackendId: meta.providerBackendId ?? membersMeta?.providerBackendId,
model: meta.model,
effort: meta.effort as TeamCreateRequest['effort'],
skipPermissions: meta.skipPermissions,

View file

@ -74,7 +74,8 @@ export class ProviderConnectionService {
async applyConfiguredConnectionEnv(
env: NodeJS.ProcessEnv,
providerId: CliProviderId
providerId: CliProviderId,
runtimeBackendOverride?: string | null
): Promise<NodeJS.ProcessEnv> {
if (providerId === 'anthropic') {
const authMode = this.getConfiguredAuthMode(providerId);
@ -109,8 +110,8 @@ export class ProviderConnectionService {
}
const codexConnection = this.configManager.getConfig().providerConnections.codex;
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend();
if (!this.shouldExposeCodexConnectionModes()) {
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride);
if (!this.shouldExposeCodexConnectionModes(runtimeBackendOverride)) {
delete env[CODEX_API_KEY_BETA_ENV_VAR];
delete env.OPENAI_API_KEY;
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
@ -172,7 +173,8 @@ export class ProviderConnectionService {
async augmentConfiguredConnectionEnv(
env: NodeJS.ProcessEnv,
providerId: CliProviderId
providerId: CliProviderId,
runtimeBackendOverride?: string | null
): Promise<NodeJS.ProcessEnv> {
if (providerId === 'anthropic') {
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
@ -191,8 +193,8 @@ export class ProviderConnectionService {
}
const codexConnection = this.configManager.getConfig().providerConnections.codex;
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend();
if (!this.shouldExposeCodexConnectionModes()) {
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride);
if (!this.shouldExposeCodexConnectionModes(runtimeBackendOverride)) {
return env;
}
@ -248,7 +250,8 @@ export class ProviderConnectionService {
async getConfiguredConnectionIssue(
env: NodeJS.ProcessEnv,
providerId: CliProviderId
providerId: CliProviderId,
runtimeBackendOverride?: string | null
): Promise<string | null> {
if (providerId === 'anthropic') {
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
@ -270,8 +273,11 @@ export class ProviderConnectionService {
}
const codexConnection = this.configManager.getConfig().providerConnections.codex;
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend();
if (!this.shouldExposeCodexConnectionModes() || codexConnection.authMode !== 'api_key') {
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride);
if (
!this.shouldExposeCodexConnectionModes(runtimeBackendOverride) ||
codexConnection.authMode !== 'api_key'
) {
return null;
}
@ -294,12 +300,17 @@ export class ProviderConnectionService {
async getConfiguredConnectionIssues(
env: NodeJS.ProcessEnv,
providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini']
providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini'],
runtimeBackendOverrides?: Partial<Record<CliProviderId, string>>
): Promise<Partial<Record<CliProviderId, string>>> {
const issues: Partial<Record<CliProviderId, string>> = {};
for (const providerId of providerIds) {
const issue = await this.getConfiguredConnectionIssue(env, providerId);
const issue = await this.getConfiguredConnectionIssue(
env,
providerId,
runtimeBackendOverrides?.[providerId]
);
if (issue) {
issues[providerId] = issue;
}
@ -369,15 +380,25 @@ export class ProviderConnectionService {
return this.apiKeyService.lookupPreferred(envVarName);
}
private getConfiguredCodexRuntimeBackend(): 'auto' | 'adapter' | 'api' | 'codex-native' {
private getConfiguredCodexRuntimeBackend(
runtimeBackendOverride?: string | null
): 'auto' | 'adapter' | 'api' | 'codex-native' {
if (
runtimeBackendOverride === 'auto' ||
runtimeBackendOverride === 'adapter' ||
runtimeBackendOverride === 'api' ||
runtimeBackendOverride === CODEX_NATIVE_BACKEND_ID
) {
return runtimeBackendOverride;
}
return this.configManager.getConfig().runtime.providerBackends.codex;
}
private shouldExposeCodexConnectionModes(): boolean {
private shouldExposeCodexConnectionModes(runtimeBackendOverride?: string | null): boolean {
const config = this.configManager.getConfig();
return (
config.providerConnections.codex.apiKeyBetaEnabled ||
config.runtime.providerBackends.codex === CODEX_NATIVE_BACKEND_ID
this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride) === CODEX_NATIVE_BACKEND_ID
);
}

View file

@ -17,6 +17,7 @@ type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined;
export interface ProviderAwareCliEnvOptions {
binaryPath?: string | null;
providerId?: ProviderEnvTargetId;
providerBackendId?: string | null;
shellEnv?: NodeJS.ProcessEnv | null;
env?: NodeJS.ProcessEnv;
connectionMode?: 'strict' | 'augment';
@ -71,21 +72,39 @@ export async function buildProviderAwareCliEnv(
if (options.providerId) {
const resolvedProviderId = resolveTeamProviderId(options.providerId);
applyProviderRuntimeEnv(env, options.providerId);
if (resolvedProviderId === 'codex' && options.providerBackendId?.trim()) {
env.CLAUDE_CODE_CODEX_BACKEND = options.providerBackendId.trim();
}
if (resolvedProviderId === 'gemini' && options.providerBackendId?.trim()) {
env.CLAUDE_CODE_GEMINI_BACKEND = options.providerBackendId.trim();
}
if (connectionMode === 'augment') {
await providerConnectionService.augmentConfiguredConnectionEnv(env, resolvedProviderId);
await providerConnectionService.augmentConfiguredConnectionEnv(
env,
resolvedProviderId,
options.providerBackendId
);
return {
env,
connectionIssues: {},
};
}
await providerConnectionService.applyConfiguredConnectionEnv(env, resolvedProviderId);
await providerConnectionService.applyConfiguredConnectionEnv(
env,
resolvedProviderId,
options.providerBackendId
);
return {
env,
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env, [
resolvedProviderId,
]),
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(
env,
[resolvedProviderId],
resolvedProviderId === 'codex' || resolvedProviderId === 'gemini'
? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined }
: undefined
),
};
}

View file

@ -58,6 +58,7 @@ const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
const TEAM_ROOT_FILES = [
'config.json',
'team.meta.json',
'kanban-state.json',
'sentMessages.json',
'sent-cross-team.json',

View file

@ -2319,6 +2319,7 @@ export class TeamDataService {
description: request.description,
color: request.color,
cwd: request.cwd?.trim() || '',
providerBackendId: request.providerBackendId,
createdAt: joinedAt,
});
@ -2349,7 +2350,10 @@ export class TeamDataService {
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
joinedAt,
}))
})),
{
providerBackendId: request.providerBackendId,
}
);
}

View file

@ -9,13 +9,22 @@ import { atomicWriteAsync } from './atomicWrite';
import type { TeamMember } from '@shared/types';
interface TeamMembersMetaFile {
export interface TeamMembersMetaFile {
version: 1;
providerBackendId?: string;
members: TeamMember[];
}
const MAX_META_FILE_BYTES = 256 * 1024;
function normalizeOptionalBackendId(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeMember(member: TeamMember): TeamMember | null {
const trimmedName = member.name?.trim();
if (!trimmedName) {
@ -45,15 +54,15 @@ export class TeamMembersMetaStore {
return path.join(getTeamsBasePath(), teamName, 'members.meta.json');
}
async getMembers(teamName: string): Promise<TeamMember[]> {
async getMeta(teamName: string): Promise<TeamMembersMetaFile | null> {
const metaPath = this.getMetaPath(teamName);
try {
const stat = await fs.promises.stat(metaPath);
if (!stat.isFile()) {
return [];
return null;
}
if (stat.isFile() && stat.size > MAX_META_FILE_BYTES) {
return [];
return null;
}
} catch {
// ignore - readFile below will handle ENOENT and throw on other errors
@ -63,10 +72,10 @@ export class TeamMembersMetaStore {
raw = await readFileUtf8WithTimeout(metaPath, 5_000);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
return null;
}
if (error instanceof FileReadTimeoutError) {
return [];
return null;
}
throw error;
}
@ -75,15 +84,15 @@ export class TeamMembersMetaStore {
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return [];
return null;
}
if (!parsed || typeof parsed !== 'object') {
return [];
return null;
}
const file = parsed as Partial<TeamMembersMetaFile>;
if (!Array.isArray(file.members)) {
return [];
return null;
}
const deduped = new Map<string, TeamMember>();
@ -107,10 +116,22 @@ export class TeamMembersMetaStore {
}
}
return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name));
return {
version: 1,
providerBackendId: normalizeOptionalBackendId(file.providerBackendId),
members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)),
};
}
async writeMembers(teamName: string, members: TeamMember[]): Promise<void> {
async getMembers(teamName: string): Promise<TeamMember[]> {
return (await this.getMeta(teamName))?.members ?? [];
}
async writeMembers(
teamName: string,
members: TeamMember[],
options?: { providerBackendId?: string }
): Promise<void> {
const deduped = new Map<string, TeamMember>();
for (const member of members) {
const normalized = normalizeMember(member);
@ -131,6 +152,7 @@ export class TeamMembersMetaStore {
const payload: TeamMembersMetaFile = {
version: 1,
providerBackendId: normalizeOptionalBackendId(options?.providerBackendId),
members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)),
};

View file

@ -19,6 +19,7 @@ export interface TeamMetaFile {
cwd: string;
prompt?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
providerBackendId?: string;
model?: string;
effort?: string;
skipPermissions?: boolean;
@ -30,6 +31,14 @@ export interface TeamMetaFile {
const MAX_META_FILE_BYTES = 256 * 1024;
function normalizeOptionalBackendId(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export class TeamMetaStore {
private getMetaPath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'team.meta.json');
@ -89,6 +98,7 @@ export class TeamMetaStore {
file.providerId === 'gemini'
? file.providerId
: undefined,
providerBackendId: normalizeOptionalBackendId(file.providerBackendId),
model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
@ -109,6 +119,7 @@ export class TeamMetaStore {
cwd: data.cwd.trim(),
prompt: data.prompt?.trim() || undefined,
providerId: data.providerId,
providerBackendId: normalizeOptionalBackendId(data.providerBackendId),
model: data.model?.trim() || undefined,
effort: data.effort?.trim() || undefined,
skipPermissions: data.skipPermissions,

View file

@ -408,7 +408,7 @@ function mergeProvisioningWarnings(
}
function buildRuntimeLaunchWarning(
request: Pick<TeamCreateRequest, 'providerId' | 'model' | 'effort'>,
request: Pick<TeamCreateRequest, 'providerId' | 'providerBackendId' | 'model' | 'effort'>,
env: NodeJS.ProcessEnv,
options?: {
geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
@ -420,7 +420,7 @@ function buildRuntimeLaunchWarning(
const providerLabel = getTeamProviderLabel(providerId);
const modelLabel = request.model?.trim() || 'default';
const effortLabel = request.effort ?? 'default';
const backend = getConfiguredRuntimeBackend(providerId);
const backend = request.providerBackendId?.trim() || getConfiguredRuntimeBackend(providerId);
const flags: string[] = [];
if (env.CLAUDE_CODE_USE_GEMINI === '1') flags.push('USE_GEMINI');
if (env.CLAUDE_CODE_USE_OPENAI === '1') flags.push('USE_OPENAI');
@ -455,7 +455,7 @@ function logRuntimeLaunchSnapshot(
teamName: string,
claudePath: string,
args: string[],
request: Pick<TeamCreateRequest, 'providerId' | 'model' | 'effort'>,
request: Pick<TeamCreateRequest, 'providerId' | 'providerBackendId' | 'model' | 'effort'>,
env: NodeJS.ProcessEnv,
options?: {
geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
@ -466,9 +466,10 @@ function logRuntimeLaunchSnapshot(
const providerId = resolveTeamProviderId(request.providerId);
const snapshot = {
providerId,
providerBackendId: request.providerBackendId ?? null,
model: request.model ?? null,
effort: request.effort ?? null,
configuredBackend: getConfiguredRuntimeBackend(providerId),
configuredBackend: request.providerBackendId?.trim() || getConfiguredRuntimeBackend(providerId),
promptSize: options?.promptSize ?? null,
expectedMembersCount: options?.expectedMembersCount ?? null,
geminiRuntimeAuth:
@ -1149,14 +1150,24 @@ function buildEffectiveTeamMemberSpecs(
}
function shouldSkipResumeForProviderRuntimeChange(
request: Pick<TeamLaunchRequest, 'providerId' | 'model'>,
config: Record<string, unknown>
request: Pick<TeamLaunchRequest, 'providerId' | 'providerBackendId' | 'model'>,
config: Record<string, unknown>,
persistedProviderBackendId?: string | null
): { skip: boolean; reason?: string } {
const providerId = normalizeTeamMemberProviderId(request.providerId);
if (providerId !== 'gemini' && providerId !== 'codex') {
return { skip: false };
}
const requestedBackendId = request.providerBackendId?.trim() || null;
const previousBackendId = persistedProviderBackendId?.trim() || null;
if (requestedBackendId && previousBackendId && requestedBackendId !== previousBackendId) {
return {
skip: true,
reason: `runtime backend changed (${previousBackendId} -> ${requestedBackendId})`,
};
}
const members = Array.isArray(config.members)
? (config.members as Record<string, unknown>[])
: [];
@ -4193,6 +4204,7 @@ export class TeamProvisioningService {
const updatedAt = nowIso();
const runId = this.getTrackedRunId(teamName);
const run = runId ? (this.runs.get(runId) ?? null) : null;
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
let configuredMembers: TeamConfig['members'] = [];
try {
@ -4294,6 +4306,7 @@ export class TeamProvisioningService {
teamName,
updatedAt,
runId: run?.runId ?? null,
providerBackendId: run?.request.providerBackendId ?? persistedTeamMeta?.providerBackendId,
members: snapshotMembers,
};
@ -5838,7 +5851,10 @@ export class TeamProvisioningService {
throw new Error('Claude CLI not found; install it or provide a valid path');
}
const provisioningEnv = await this.buildProvisioningEnv(request.providerId);
const provisioningEnv = await this.buildProvisioningEnv(
request.providerId,
request.providerBackendId
);
const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv;
if (envWarning) {
throw new Error(envWarning);
@ -6042,6 +6058,7 @@ export class TeamProvisioningService {
cwd: request.cwd,
prompt: request.prompt,
providerId: request.providerId,
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
skipPermissions: request.skipPermissions,
@ -6065,7 +6082,10 @@ export class TeamProvisioningService {
agentType: 'general-purpose' as const,
color: getMemberColorByName(m.name.trim()),
joinedAt: Date.now(),
}))
})),
{
providerBackendId: request.providerBackendId,
}
);
if (request.skipPermissions === false) {
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
@ -6310,7 +6330,14 @@ export class TeamProvisioningService {
if (!skipResume) {
try {
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
const resumeGuard = shouldSkipResumeForProviderRuntimeChange(request, configParsed);
const persistedTeamMeta = await this.teamMetaStore
.getMeta(request.teamName)
.catch(() => null);
const resumeGuard = shouldSkipResumeForProviderRuntimeChange(
request,
configParsed,
persistedTeamMeta?.providerBackendId ?? null
);
if (resumeGuard.skip) {
logger.info(
`[${request.teamName}] Skipping session resume — ${resumeGuard.reason ?? 'runtime changed'}`
@ -6395,7 +6422,10 @@ export class TeamProvisioningService {
const runId = randomUUID();
const startedAt = nowIso();
const provisioningEnv = await this.buildProvisioningEnv(request.providerId);
const provisioningEnv = await this.buildProvisioningEnv(
request.providerId,
request.providerBackendId
);
const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv;
if (envWarning) {
throw new Error(envWarning);
@ -6421,6 +6451,7 @@ export class TeamProvisioningService {
members: effectiveMemberSpecs,
cwd: request.cwd,
providerId: request.providerId,
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
skipPermissions: request.skipPermissions,
@ -6643,6 +6674,42 @@ export class TeamProvisioningService {
});
// --resume is added above when a valid previous session JSONL exists.
// Without it, CLI creates a fresh session ID automatically.
await this.teamMetaStore.writeMeta(request.teamName, {
displayName: syntheticRequest.displayName,
description: syntheticRequest.description,
color: syntheticRequest.color,
cwd: request.cwd,
prompt: request.prompt,
providerId: request.providerId,
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
skipPermissions: request.skipPermissions,
worktree: request.worktree,
extraCliArgs: request.extraCliArgs,
limitContext: request.limitContext,
createdAt: Date.now(),
});
await this.membersMetaStore.writeMembers(
request.teamName,
effectiveMemberSpecs.map((member) => ({
name: member.name.trim(),
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
joinedAt: Date.now(),
})),
{
providerBackendId: request.providerBackendId,
}
);
try {
if (request.skipPermissions === false) {
@ -11253,9 +11320,6 @@ export class TeamProvisioningService {
}
);
// Clean up team.meta.json — provisioning succeeded, config.json is now authoritative.
await this.teamMetaStore.deleteMeta(run.teamName).catch(() => {});
// Audit: flag any expected member not registered in config.json after provisioning.
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
await this.maybeAuditMemberSpawnStatuses(run, { force: true });
@ -12182,7 +12246,8 @@ export class TeamProvisioningService {
}
private async buildProvisioningEnv(
providerId: TeamProviderId | undefined = 'anthropic'
providerId: TeamProviderId | undefined = 'anthropic',
providerBackendId?: string | null
): Promise<ProvisioningEnvResolution> {
const shellEnv = await resolveInteractiveShellEnv();
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
@ -12228,6 +12293,7 @@ export class TeamProvisioningService {
const resolvedProviderId = resolveTeamProviderId(providerId);
const providerEnvResult = await buildProviderAwareCliEnv({
providerId,
providerBackendId,
shellEnv,
env,
});
@ -13059,7 +13125,10 @@ export class TeamProvisioningService {
agentType: 'general-purpose',
color: getMemberColorByName(member.name.trim()),
joinedAt,
}))
})),
{
providerBackendId: request.providerBackendId,
}
);
} catch (error) {
logger.warn(

View file

@ -41,6 +41,7 @@ import {
normalizeCreateLaunchProviderForUi,
} from '@renderer/utils/geminiUiFreeze';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { resolveEffectiveProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import {
getTeamModelSelectionError,
normalizeExplicitTeamModelForUi,
@ -972,6 +973,9 @@ export const CreateTeamDialog = ({
cwd: effectiveCwd,
prompt: prompt.trim() || undefined,
providerId: selectedProviderId,
providerBackendId:
resolveEffectiveProviderBackendId(runtimeProviderStatusById.get(selectedProviderId)) ??
undefined,
model: effectiveModel,
effort: (selectedEffort as EffortLevel) || undefined,
limitContext,
@ -988,6 +992,7 @@ export const CreateTeamDialog = ({
effectiveCwd,
prompt,
selectedProviderId,
runtimeProviderStatusById,
effectiveModel,
selectedEffort,
limitContext,

View file

@ -45,6 +45,7 @@ import {
normalizeCreateLaunchProviderForUi,
} from '@renderer/utils/geminiUiFreeze';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { resolveEffectiveProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import { nameColorSet } from '@renderer/utils/projectColor';
import {
getTeamModelSelectionError,
@ -319,6 +320,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
);
const members = isLaunch ? props.members : storeMembers;
const [savedLaunchProviderId, setSavedLaunchProviderId] = useState<TeamProviderId | null>(null);
const [savedLaunchProviderBackendId, setSavedLaunchProviderBackendId] = useState<string | null>(
null
);
// Advanced CLI section state (with localStorage persistence)
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
@ -623,6 +627,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
: savedRequest?.providerId === 'anthropic'
? 'anthropic'
: null;
const savedProviderBackendId =
typeof savedRequest?.providerBackendId === 'string' &&
savedRequest.providerBackendId.trim().length > 0
? savedRequest.providerBackendId.trim()
: null;
const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled);
const launchPrefill = resolveLaunchDialogPrefill({
members,
@ -635,6 +644,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
getStoredModel: getStoredTeamModel,
});
setSavedLaunchProviderId(savedProviderId);
setSavedLaunchProviderBackendId(
launchPrefill.providerBackendId ?? savedProviderBackendId ?? null
);
setMembersDrafts(
createMemberDraftsFromInputs(editableMembersSource).map((member) =>
@ -1390,6 +1402,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined,
providerId: selectedProviderId,
providerBackendId:
resolveEffectiveProviderBackendId(
runtimeProviderStatusById.get(selectedProviderId)
) ??
previousLaunchParams?.providerBackendId ??
savedLaunchProviderBackendId ??
undefined,
model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
effort: (selectedEffort as EffortLevel) || undefined,
limitContext,

View file

@ -8,6 +8,7 @@ import type { ResolvedTeamMember, TeamCreateRequest, TeamProviderId } from '@sha
interface PreviousLaunchParamsLike {
providerId?: TeamProviderId;
providerBackendId?: string;
model?: string;
effort?: string;
limitContext?: boolean;
@ -26,6 +27,7 @@ interface LaunchDialogPrefillInput {
interface LaunchDialogPrefillResult {
providerId: TeamProviderId;
providerBackendId?: string;
model: string;
effort: string;
limitContext: boolean;
@ -101,6 +103,10 @@ export function resolveLaunchDialogPrefill({
return {
providerId,
providerBackendId:
previousLaunchParams?.providerBackendId?.trim() ||
savedRequest?.providerBackendId?.trim() ||
undefined,
model: matchingModel
? normalizeExplicitTeamModelForUi(providerId, matchingModel)
: getStoredModel(providerId),

View file

@ -166,6 +166,7 @@ function areLaunchParamsEquivalent(
if (!left || !right) return left === right;
return (
left.providerId === right.providerId &&
left.providerBackendId === right.providerBackendId &&
left.model === right.model &&
left.effort === right.effort
);

View file

@ -1523,6 +1523,7 @@ export interface GlobalTaskDetailState {
/** Per-team launch parameters shown in the header badge. */
export interface TeamLaunchParams {
providerId?: 'anthropic' | 'codex' | 'gemini';
providerBackendId?: string;
model?: string; // 'opus' | 'sonnet' | 'haiku'
effort?: EffortLevel;
limitContext?: boolean;
@ -4419,6 +4420,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const baseModel = extractBaseModel(request.model, request.providerId);
const params: TeamLaunchParams = {
providerId: request.providerId ?? 'anthropic',
providerBackendId: request.providerBackendId,
model: baseModel || 'default',
effort: request.effort,
limitContext: request.limitContext ?? false,
@ -4590,6 +4592,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const baseModel = extractBaseModel(request.model, request.providerId);
const params: TeamLaunchParams = {
providerId: request.providerId ?? 'anthropic',
providerBackendId: request.providerBackendId,
model: baseModel || 'default',
effort: request.effort,
limitContext: request.limitContext ?? false,

View file

@ -1,5 +1,6 @@
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
import { formatBytes } from '@renderer/utils/formatters';
import { formatTeamProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
@ -34,6 +35,10 @@ export function resolveMemberRuntimeSummary(
const configuredModel = member.model?.trim() || launchParams?.model?.trim() || '';
const configuredEffort = member.effort ?? launchParams?.effort;
const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim();
const backendLabel = formatTeamProviderBackendLabel(
configuredProvider,
launchParams?.providerBackendId
);
const memorySuffix =
typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0
? ` · ${formatBytes(runtimeEntry.rssBytes)}`
@ -41,12 +46,14 @@ export function resolveMemberRuntimeSummary(
if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {
const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider;
return `${formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort)}${memorySuffix}`;
const summary = formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort);
return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`;
}
if (isMemberLaunchPending(spawnEntry)) {
return undefined;
}
return `${formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort)}${memorySuffix}`;
const summary = formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort);
return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`;
}

View file

@ -0,0 +1,53 @@
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
function normalizeOptionalBackendId(value: string | null | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveEffectiveProviderBackendId(
provider: Pick<CliProviderStatus, 'selectedBackendId' | 'resolvedBackendId'> | null | undefined
): string | undefined {
return normalizeOptionalBackendId(provider?.resolvedBackendId ?? provider?.selectedBackendId);
}
export function formatTeamProviderBackendLabel(
providerId: TeamProviderId | undefined,
providerBackendId: string | undefined
): string | undefined {
const normalizedProviderId = providerId ?? 'anthropic';
const normalizedBackendId = normalizeOptionalBackendId(providerBackendId);
if (!normalizedBackendId) {
return undefined;
}
if (normalizedProviderId === 'codex') {
switch (normalizedBackendId) {
case 'codex-native':
return 'Codex native';
case 'adapter':
return 'Default adapter';
case 'api':
return 'OpenAI API';
case 'auto':
return undefined;
default:
return normalizedBackendId;
}
}
if (normalizedProviderId === 'gemini') {
switch (normalizedBackendId) {
case 'cli-sdk':
return 'CLI SDK';
case 'api':
return 'API';
case 'auto':
return undefined;
default:
return normalizedBackendId;
}
}
return normalizedBackendId;
}

View file

@ -784,12 +784,14 @@ export interface TeamViewSnapshot {
export type EffortLevel = 'low' | 'medium' | 'high';
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
export type TeamProviderBackendId = string;
export interface TeamLaunchRequest {
teamName: string;
cwd: string;
prompt?: string;
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
/** When true, context window is limited to 200K tokens instead of the default. */
@ -928,6 +930,7 @@ export interface TeamAgentRuntimeSnapshot {
teamName: string;
updatedAt: string;
runId: string | null;
providerBackendId?: TeamProviderBackendId;
members: Record<string, TeamAgentRuntimeEntry>;
}
@ -1037,6 +1040,7 @@ export interface TeamCreateRequest {
cwd: string;
prompt?: string;
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
/** When true, context window is limited to 200K tokens instead of the default. */
@ -1056,6 +1060,7 @@ export interface TeamCreateConfigRequest {
color?: string;
members: TeamProvisioningMemberInput[];
cwd?: string;
providerBackendId?: TeamProviderBackendId;
}
export interface TeamCreateResponse {

View file

@ -98,7 +98,8 @@ describe('buildProviderAwareCliEnv', () => {
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1',
CLAUDE_CODE_ENTRY_PROVIDER: 'anthropic',
}),
'anthropic'
'anthropic',
undefined
);
expect(result.connectionIssues).toEqual({
anthropic: 'missing key',
@ -163,7 +164,8 @@ describe('buildProviderAwareCliEnv', () => {
HOME: '/Users/electron-home',
USERPROFILE: '/Users/electron-home',
}),
'anthropic'
'anthropic',
undefined
);
expect(result.env.HOME).toBe('/Users/electron-home');
expect(result.env.USERPROFILE).toBe('/Users/electron-home');
@ -208,7 +210,8 @@ describe('buildProviderAwareCliEnv', () => {
CLAUDE_CODE_CODEX_BACKEND: 'adapter',
CLAUDE_CODE_CODEX_NATIVE_INTERNAL_UNLOCK: '1',
}),
'codex'
'codex',
undefined
);
expect(result.env.CLAUDE_CODE_CODEX_NATIVE_INTERNAL_UNLOCK).toBe('1');
});

View file

@ -442,6 +442,50 @@ describe('TeamProvisioningService', () => {
});
});
it('exposes providerBackendId from the live run request when available', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({ providerBackendId: 'adapter' })),
};
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
(svc as any).runs.set('run-1', {
runId: 'run-1',
child: { pid: 111 },
request: { model: 'gpt-5.4', providerBackendId: 'codex-native' },
processKilled: false,
cancelRequested: false,
spawnContext: null,
});
vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000),
} as any);
const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
expect(snapshot.providerBackendId).toBe('codex-native');
});
it('falls back to persisted team meta backend when no live run exists', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({ providerBackendId: 'codex-native' })),
};
const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
expect(snapshot.providerBackendId).toBe('codex-native');
});
it('falls back to per-pid pidusage reads when batched sampling fails', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
@ -969,6 +1013,63 @@ describe('TeamProvisioningService', () => {
expect(launchArgs).toContain(leadSessionId);
});
it('skips --resume when the persisted runtime backend lane changed', async () => {
allowConsoleLogs();
const teamName = 'resume-backend-change-team';
const leadSessionId = 'lead-session-backend-change';
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockImplementation(() => {
throw new Error('launch spawn EINVAL');
});
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
removeConfigFile: vi.fn(async () => {}),
} as any);
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { CODEX_API_KEY: 'test' },
authSource: 'codex_runtime',
}));
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: [{ name: 'alice' }],
source: 'members-meta',
warning: undefined,
}));
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
targetPath.endsWith(`${leadSessionId}.jsonl`)
);
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({ providerBackendId: 'adapter' })),
writeMeta: vi.fn(async () => {}),
deleteMeta: vi.fn(async () => {}),
};
await expect(
svc.launchTeam(
{
teamName,
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
},
() => {}
)
).rejects.toThrow('launch spawn EINVAL');
const launchArgs = vi.mocked(spawnCli).mock.calls.at(-1)?.[1] as string[];
expect(launchArgs).toBeTruthy();
expect(launchArgs).not.toContain('--resume');
expect(launchArgs).not.toContain(leadSessionId);
});
it('seeds the current lead session id immediately when launch resumes an existing session', async () => {
allowConsoleLogs();
const teamName = 'resume-seed-session-team';

View file

@ -101,6 +101,7 @@ describe('resolveLaunchDialogPrefill', () => {
savedRequest: null,
previousLaunchParams: {
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.3-codex',
effort: 'high',
},
@ -116,12 +117,45 @@ describe('resolveLaunchDialogPrefill', () => {
expect(result).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.3-codex',
effort: 'high',
limitContext: false,
});
});
it('falls back to a saved request backend lane when no previous launch params exist', () => {
const result = resolveLaunchDialogPrefill({
members: [],
savedRequest: {
teamName: 'vector-room-2',
cwd: '/Users/test/project',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
members: [],
} as TeamCreateRequest,
previousLaunchParams: undefined,
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
codex: 'gpt-5.4',
}),
});
expect(result).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
limitContext: false,
});
});
it('does not carry a frozen Gemini model into an Anthropic fallback', () => {
const members = [
{

View file

@ -2934,6 +2934,49 @@ describe('teamSlice actions', () => {
});
describe('provisioning run scoping', () => {
it('persists providerBackendId into createTeam launch params', async () => {
const store = createSliceStore();
await store.getState().createTeam({
teamName: 'my-team',
cwd: '/tmp/project',
members: [],
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
});
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
limitContext: false,
});
});
it('persists providerBackendId into launchTeam launch params', async () => {
const store = createSliceStore();
await store.getState().launchTeam({
teamName: 'my-team',
cwd: '/tmp/project',
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
});
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
limitContext: false,
});
});
it('rolls back optimistic pending run on early createTeam failure', async () => {
const store = createSliceStore();
hoisted.createTeam.mockRejectedValue(new Error('create failed'));

View file

@ -80,4 +80,22 @@ describe('resolveMemberRuntimeSummary', () => {
'5.4 Mini · Medium · 256.0 MB'
);
});
it('keeps the persisted backend lane visible in the runtime summary', () => {
const member = createMember({ model: 'gpt-5.4-mini' });
expect(
resolveMemberRuntimeSummary(
member,
{
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4-mini',
effort: 'medium',
limitContext: false,
},
undefined
)
).toBe('5.4 Mini · Medium · Codex native');
});
});