🪪 feat: Add Per-Agent Git Identity (#15628)

* feat: Add per-agent Git identity

* fix: annotate git identity schema

* fix: complete git identity controls

* fix: clear git identity on version restore

* fix: Require a complete agent Git identity

* fix: Complete Git Identity Across Bash And Form Validation

* fix: Discard Incomplete Hidden Git Identity Edits

* fix: validate Git identity across panel lifecycle

* fix: preserve saved Git identity when discarding drafts
This commit is contained in:
Danny Avila 2026-09-05 23:56:23 -04:00 • committed by GitHub
parent c9b9644163
commit aeac7a2065
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 567 additions and 10 deletions

View file

@ -1060,6 +1060,7 @@ const updateAgentHandler = async (req, res) => {
const {
avatar: avatarField,
code_environment_id: codeEnvironmentIdField,
git_identity: gitIdentityField,
_id,
...rest
} = validatedData;
@ -1067,6 +1068,9 @@ const updateAgentHandler = async (req, res) => {
if (codeEnvironmentIdField !== undefined) {
updateData.code_environment_id = codeEnvironmentIdField;
}
if (gitIdentityField !== undefined) {
updateData.git_identity = gitIdentityField;
}
let existingAgent;
const includesStatefulConfiguration =
@ -1310,6 +1314,10 @@ const updateAgentHandler = async (req, res) => {
delete updateData.code_environment_id;
updateData.$unset = { code_environment_id: 1 };
}
if (updateData.git_identity === null) {
delete updateData.git_identity;
updateData.$unset = { ...updateData.$unset, git_identity: 1 };
}
let updatedAgent =
Object.keys(updateData).length > 0

View file

@ -1675,6 +1675,21 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.code_environment_id).toBeUndefined();
});
test('clears a configured Git identity', async () => {
await Agent.updateOne(
{ id: existingAgentId },
{ git_identity: { name: 'Coding Agent', email: 'agent@example.com' } },
);
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = { git_identity: null };
await updateAgentHandler(mockReq, mockRes);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.git_identity).toBeUndefined();
});
test('allows unrelated edits to an existing scope after policy is tightened', async () => {
await Agent.updateOne(
{ id: existingAgentId },

View file

@ -46,6 +46,7 @@ const {
isFatalAgentInitializationError,
codeExecutionAuthHeaders,
createAttachedWorkspaceBashTool,
createGitIdentityProgrammaticBashTool,
resolveCodeExecutionContext,
resolveCallerCapabilityProjectionSnapshot,
LIST_WORKSPACE_FILES_TOOL_NAME,
@ -2093,7 +2094,7 @@ async function loadToolsForExecution({
* library so PTC calls share the same managed auth context.
*/
for (const name of ptcToolNames) {
const ptcTool = createBashProgrammaticToolCallingTool({
const ptcOptions = {
authHeaders: () =>
codeExecutionAuthHeaders(
(bridgeWorkerId) => getCodeApiAuthHeaders(req, bridgeWorkerId),
@ -2102,7 +2103,11 @@ async function loadToolsForExecution({
baseUrl: codeExecutionContext.baseUrl,
executionProfile: codeExecutionContext.executionProfile,
runtimeSessionHint: codeExecutionContext.runtimeSessionHint,
});
};
const ptcTool =
codeExecutionContext.environmentType === 'attached'
? createGitIdentityProgrammaticBashTool(ptcOptions, agent?.git_identity)
: createBashProgrammaticToolCallingTool(ptcOptions);
ptcTool.name = name;
allLoadedTools.push(ptcTool);
}
@ -2133,6 +2138,7 @@ async function loadToolsForExecution({
? createAttachedWorkspaceBashTool({
authHeaders,
baseUrl: codeExecutionContext.baseUrl,
gitIdentity: agent?.git_identity,
})
: createBashExecutionTool({
authHeaders,

View file

@ -2661,6 +2661,7 @@ describe('ToolService - Action Capability Gating', () => {
tools: [Tools.execute_code],
stateful_code_sessions: true,
stateful_code_environment: 'agent-user',
git_identity: { name: 'LibreChat Agent', email: 'agent@example.com' },
},
toolNames: [AgentConstants.BASH_TOOL],
toolRegistry,
@ -2670,6 +2671,7 @@ describe('ToolService - Action Capability Gating', () => {
expect(mockCreateAttachedWorkspaceBashTool).toHaveBeenCalledWith({
authHeaders: expect.any(Function),
baseUrl: 'http://attached-code.test/v1',
gitIdentity: { name: 'LibreChat Agent', email: 'agent@example.com' },
});
expect(result.loadedTools).toContainEqual({ name: AgentConstants.BASH_TOOL });
});

View file

@ -52,6 +52,8 @@ export type AgentForm = {
stateful_code_environment?: StatefulCodeEnvironment;
/** Operator-configured managed or attached execution environment. */
code_environment_id?: string | null;
/** Git authorship applied to sandboxed commands for this agent. */
git_identity?: Agent['git_identity'];
provider?: AgentProvider | OptionWithIcon;
/** @deprecated Use edges instead */
agent_ids?: string[];

View file

@ -1,6 +1,8 @@
import { useCallback, useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import {
AgentCapabilities,
agentGitIdentitySchema,
STATEFUL_CODE_ENVIRONMENTS,
resolveStatefulCodeEnvironment,
resolveAllowedStatefulCodeEnvironments,
@ -17,6 +19,7 @@ import {
HoverCardContent,
HoverCardTrigger,
CircleHelpIcon,
Input,
} from '@librechat/client';
import type { StatefulCodeEnvironment } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
@ -36,7 +39,14 @@ export default function StatefulSessions() {
const { user } = useAuthContext();
const { agentsConfig } = useGetAgentsConfig();
const methods = useFormContext<AgentForm>();
const { setValue, watch } = methods;
const {
register,
unregister,
getValues,
setValue,
watch,
formState: { errors },
} = methods;
const enabled = watch(AgentCapabilities.stateful_code_sessions) ?? false;
const codeEnabled = watch(AgentCapabilities.execute_code);
@ -45,6 +55,32 @@ export default function StatefulSessions() {
const configuredEnvironments = agentsConfig?.statefulCodeSessions?.allowedEnvironments;
const executionEnvironments = agentsConfig?.statefulCodeSessions?.environments ?? [];
const allowedEnvironments = resolveAllowedStatefulCodeEnvironments(configuredEnvironments);
const effectiveExecutionEnvironment = codeEnvironmentId
? executionEnvironments.find((candidate) => candidate.id === codeEnvironmentId)
: executionEnvironments.find((candidate) => candidate.default === true);
const showGitIdentity =
codeEnabled && enabled && effectiveExecutionEnvironment?.type === 'attached';
const releaseIdentity = useCallback(() => {
const identity = getValues('git_identity');
const empty = !identity?.name?.trim() && !identity?.email?.trim();
unregister('git_identity', {
keepValue: empty || agentGitIdentitySchema.safeParse(identity).success,
keepDefaultValue: true,
});
}, [getValues, unregister]);
useEffect(() => {
if (!showGitIdentity) releaseIdentity();
}, [showGitIdentity, releaseIdentity]);
useEffect(() => releaseIdentity, [releaseIdentity]);
const validateGitIdentity = (_value: string | undefined, values: AgentForm) => {
const identity = values.git_identity;
const empty = !identity?.name?.trim() && !identity?.email?.trim();
return (
empty ||
agentGitIdentitySchema.safeParse(identity).success ||
localize('com_ui_agent_git_identity_both_required')
);
};
const handleChange = (value: boolean) => {
setValue(AgentCapabilities.stateful_code_sessions, value, { shouldDirty: true });
@ -170,6 +206,40 @@ export default function StatefulSessions() {
<p className="text-xs text-text-tertiary">
{localize('com_nav_info_stateful_code_environment')}
</p>
{showGitIdentity && (
<div className="space-y-2 border-t border-border-light pt-3">
<div className="text-xs font-medium text-text-secondary">
{localize('com_ui_agent_git_identity')}
</div>
<Input
{...register('git_identity.name', {
validate: validateGitIdentity,
deps: ['git_identity.email'],
})}
maxLength={128}
placeholder={localize('com_ui_agent_git_name')}
aria-label={localize('com_ui_agent_git_name')}
/>
<Input
{...register('git_identity.email', {
validate: validateGitIdentity,
deps: ['git_identity.name'],
})}
type="email"
maxLength={254}
placeholder={localize('com_ui_agent_git_email')}
aria-label={localize('com_ui_agent_git_email')}
/>
{(errors.git_identity?.name || errors.git_identity?.email) && (
<p className="text-xs text-text-destructive" role="alert">
{localize('com_ui_agent_git_identity_both_required')}
</p>
)}
<p className="text-xs text-text-tertiary">
{localize('com_nav_info_agent_git_identity')}
</p>
</div>
)}
</div>
)}
</div>

View file

@ -91,6 +91,7 @@ export function composeAgentUpdatePayload(
stateful_code_sessions,
stateful_code_environment,
code_environment_id,
git_identity,
recursion_limit,
category,
support_contact,
@ -122,6 +123,17 @@ export function composeAgentUpdatePayload(
const model_parameters = modelParameterSettings
? pruneAgentModelParameters(currentModelParameters, modelParameterSettings)
: currentModelParameters;
let normalizedGitIdentity: AgentUpdateParams['git_identity'];
const gitIdentityName = git_identity?.name?.trim() ?? '';
const gitIdentityEmail = git_identity?.email?.trim() ?? '';
if (gitIdentityName || gitIdentityEmail) {
normalizedGitIdentity = {
name: gitIdentityName,
email: gitIdentityEmail,
};
} else if (agent_id && git_identity != null) {
normalizedGitIdentity = null;
}
return {
payload: {
@ -140,6 +152,7 @@ export function composeAgentUpdatePayload(
stateful_code_sessions: normalizedStatefulCodeSessions,
stateful_code_environment: normalizedStatefulCodeEnvironment,
code_environment_id: agent_id ? code_environment_id : (code_environment_id ?? undefined),
git_identity: normalizedGitIdentity,
recursion_limit,
category,
support_contact,
@ -640,7 +653,13 @@ export default function AgentPanel() {
});
}
create.mutate({ ...basePayload, model, tools, provider });
create.mutate({
...basePayload,
git_identity: basePayload.git_identity ?? undefined,
model,
tools,
provider,
});
},
[
agent_id,

View file

@ -101,6 +101,7 @@ function AgentSelect({
avatar_action: null,
stateful_code_environment: fullAgent.stateful_code_environment ?? 'user',
code_environment_id: fullAgent.code_environment_id,
git_identity: fullAgent.git_identity,
};
Object.entries(fullAgent).forEach(([name, value]) => {

View file

@ -2,7 +2,11 @@
* @jest-environment jsdom
*/
import { Constants, EModelEndpoint, type Agent } from 'librechat-data-provider';
import type { AgentModelParameters } from 'librechat-data-provider';
import type {
AgentCreateParams,
AgentUpdateParams,
AgentModelParameters,
} from 'librechat-data-provider';
import type { FieldNamesMarkedBoolean } from 'react-hook-form';
import type { AgentForm } from '~/common';
import {
@ -13,6 +17,13 @@ import {
mayHavePersistedChange,
} from '../AgentPanel';
test('the create identity contract excludes the update-only clear sentinel', () => {
const createAcceptsNull: null extends AgentCreateParams['git_identity'] ? true : false = false;
const updateAcceptsNull: null extends AgentUpdateParams['git_identity'] ? true : false = true;
expect(createAcceptsNull).toBe(false);
expect(updateAcceptsNull).toBe(true);
});
const createForm = (): AgentForm => ({
agent: undefined,
id: 'agent_123',
@ -160,6 +171,45 @@ describe('composeAgentUpdatePayload', () => {
expect(payload.code_environment_id).toBeUndefined();
});
it('normalizes a configured Git identity', () => {
const form = createForm();
form.git_identity = { name: ' Coding Agent ', email: ' agent@example.com ' };
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
expect(payload.git_identity).toEqual({
name: 'Coding Agent',
email: 'agent@example.com',
});
});
it('clears an empty Git identity when updating an agent', () => {
const form = createForm();
form.git_identity = { name: '', email: '' };
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
expect(payload.git_identity).toBeNull();
});
it('does not turn a partially filled Git identity into a clear operation', () => {
const form = createForm();
form.git_identity = { name: 'Coding Agent', email: '' };
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
expect(payload.git_identity).toEqual({ name: 'Coding Agent', email: '' });
});
it('omits an empty Git identity when creating an agent', () => {
const form = createForm();
form.git_identity = { name: '', email: '' };
const { payload } = composeAgentUpdatePayload(form);
expect(payload.git_identity).toBeUndefined();
});
it('persists standalone skill authoring separately from catalog access', () => {
const form = createForm();
form.skills = [];

View file

@ -0,0 +1,144 @@
/** @jest-environment jsdom */
/* eslint-disable i18next/no-literal-string -- Test harness controls are not product UI. */
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { AgentCapabilities } from 'librechat-data-provider';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { AgentForm } from '~/common';
import StatefulSessions from '../Advanced/StatefulSessions';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useAuthContext: () => ({ user: {} }),
useGetAgentsConfig: () => ({
agentsConfig: {
statefulCodeSessions: {
environments: [{ id: 'byom', name: 'My machine', type: 'attached', default: true }],
},
},
}),
}));
function IdentityForm({ savedIdentity }: { savedIdentity?: AgentForm['git_identity'] }) {
const [advanced, setAdvanced] = useState(true);
const methods = useForm<AgentForm>({
mode: 'onChange',
defaultValues: {
execute_code: true,
stateful_code_sessions: true,
git_identity: savedIdentity,
},
});
return (
<FormProvider {...methods}>
{advanced && <StatefulSessions />}
<button onClick={() => setAdvanced(!advanced)}>Toggle Advanced</button>
<button onClick={() => methods.setValue(AgentCapabilities.execute_code, false)}>
Disable code
</button>
<button onClick={() => methods.setValue('code_environment_id', 'managed')}>
Use managed
</button>
<button onClick={() => methods.setValue(AgentCapabilities.stateful_code_sessions, false)}>
Disable sessions
</button>
<output data-testid="identity">{JSON.stringify(methods.watch('git_identity'))}</output>
</FormProvider>
);
}
test.each(['', 'not-an-email'])(
'restores the saved identity when reopening an invalid draft: %s',
async (email) => {
render(<IdentityForm savedIdentity={{ name: 'Saved Agent', email: 'saved@example.com' }} />);
fireEvent.change(screen.getByLabelText('com_ui_agent_git_email'), {
target: { value: email },
});
expect(await screen.findByRole('alert')).toBeInTheDocument();
fireEvent.click(screen.getByText('Toggle Advanced'));
fireEvent.click(screen.getByText('Toggle Advanced'));
expect(screen.getByLabelText('com_ui_agent_git_name')).toHaveValue('Saved Agent');
expect(screen.getByLabelText('com_ui_agent_git_email')).toHaveValue('saved@example.com');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
},
);
test('clears cross-field errors when completing or clearing a Git identity', async () => {
render(<IdentityForm />);
const name = screen.getByLabelText('com_ui_agent_git_name');
const email = screen.getByLabelText('com_ui_agent_git_email');
fireEvent.change(name, { target: { value: 'Coding Agent' } });
expect(await screen.findByRole('alert')).toBeInTheDocument();
fireEvent.change(email, { target: { value: 'agent@example.com' } });
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
fireEvent.change(name, { target: { value: '' } });
expect(await screen.findByRole('alert')).toBeInTheDocument();
fireEvent.change(email, { target: { value: '' } });
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
});
test.each(['Disable code', 'Use managed', 'Disable sessions', 'Toggle Advanced'])(
'discards a partial identity when its controls disappear: %s',
async (action) => {
render(<IdentityForm />);
fireEvent.change(screen.getByLabelText('com_ui_agent_git_name'), {
target: { value: 'Partial Agent' },
});
expect(await screen.findByRole('alert')).toBeInTheDocument();
fireEvent.click(screen.getByText(action));
await waitFor(() => expect(screen.getByTestId('identity')).toBeEmptyDOMElement());
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
},
);
test.each(['Disable code', 'Use managed', 'Disable sessions', 'Toggle Advanced'])(
'discards an invalid email when its controls disappear: %s',
async (action) => {
render(<IdentityForm />);
fireEvent.change(screen.getByLabelText('com_ui_agent_git_name'), {
target: { value: 'Coding Agent' },
});
fireEvent.change(screen.getByLabelText('com_ui_agent_git_email'), {
target: { value: 'not-an-email' },
});
expect(await screen.findByRole('alert')).toBeInTheDocument();
fireEvent.click(screen.getByText(action));
await waitFor(() => expect(screen.getByTestId('identity')).toBeEmptyDOMElement());
},
);
test.each([
{ name: 'Coding Agent', email: 'agent@example.com' },
{ name: '', email: '' },
])('retains a valid identity or explicit clear through panel navigation: %j', async (identity) => {
render(<IdentityForm />);
fireEvent.change(screen.getByLabelText('com_ui_agent_git_name'), {
target: { value: identity.name },
});
fireEvent.change(screen.getByLabelText('com_ui_agent_git_email'), {
target: { value: identity.email },
});
fireEvent.click(screen.getByText('Toggle Advanced'));
expect(screen.getByTestId('identity')).toHaveTextContent(JSON.stringify(identity));
fireEvent.click(screen.getByText('Toggle Advanced'));
expect(screen.getByLabelText('com_ui_agent_git_name')).toHaveValue(identity.name);
expect(screen.getByLabelText('com_ui_agent_git_email')).toHaveValue(identity.email);
fireEvent.change(screen.getByLabelText('com_ui_agent_git_email'), {
target: { value: 'invalid' },
});
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
test('preserves a complete identity when switching away from the attached environment', async () => {
render(<IdentityForm />);
fireEvent.change(screen.getByLabelText('com_ui_agent_git_name'), {
target: { value: 'Coding Agent' },
});
fireEvent.change(screen.getByLabelText('com_ui_agent_git_email'), {
target: { value: 'agent@example.com' },
});
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
fireEvent.click(screen.getByText('Use managed'));
expect(screen.getByTestId('identity')).toHaveTextContent('Coding Agent');
expect(screen.getByTestId('identity')).toHaveTextContent('agent@example.com');
});

View file

@ -2106,6 +2106,11 @@
"com_ui_skill_instructions": "Instructions",
"com_ui_skill_instructions_placeholder": "Enter your skill instructions in markdown...",
"com_ui_skill_name_invalid": "Use lowercase letters, digits, and dashes only (kebab-case)",
"com_ui_agent_git_identity": "Git identity",
"com_ui_agent_git_identity_both_required": "Enter a valid Git name and email, or leave both blank.",
"com_ui_agent_git_name": "Commit name",
"com_ui_agent_git_email": "Commit email",
"com_nav_info_agent_git_identity": "Applied as the author and committer for Git commands run by this agent. Credentials are configured separately.",
"com_ui_skill_name_placeholder": "brand-guidelines",
"com_ui_skill_name_required": "Name is required",
"com_ui_skill_name_too_long": "Name must be {{0}} characters or fewer",

View file

@ -36,6 +36,20 @@ describe('getLazySubagentConfigId', () => {
);
});
it('changes when the Git author identity changes', () => {
expect(
getLazySubagentConfigId({
...agent,
git_identity: { name: 'First Agent', email: 'first@example.com' },
}),
).not.toBe(
getLazySubagentConfigId({
...agent,
git_identity: { name: 'Second Agent', email: 'second@example.com' },
}),
);
});
it('is stable across key order and excludes secret values', () => {
const first = getLazySubagentConfigId({
...agent,

View file

@ -23,6 +23,7 @@ type VersionedAgent = Pick<
| 'stateful_code_sessions'
| 'stateful_code_environment'
| 'code_environment_id'
| 'git_identity'
| 'artifacts'
| 'recursion_limit'
| 'agent_ids'
@ -95,6 +96,7 @@ export function selectLazySubagentConfig(agent: VersionedAgent): Omit<VersionedA
stateful_code_sessions,
stateful_code_environment,
code_environment_id,
git_identity,
artifacts,
recursion_limit,
agent_ids,
@ -127,6 +129,7 @@ export function selectLazySubagentConfig(agent: VersionedAgent): Omit<VersionedA
stateful_code_sessions,
stateful_code_environment,
code_environment_id,
git_identity,
artifacts,
recursion_limit,
agent_ids,

View file

@ -47,6 +47,7 @@ describe('Agent Management contract', () => {
model: 'gpt-5',
name: 'Researcher',
stateful_code_environment: 'agent-user',
git_identity: { name: 'LibreChat Agent', email: 'agent@example.com' },
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
}),
).toMatchObject({ provider: 'openAI', model: 'gpt-5', tools: [] });
@ -56,11 +57,13 @@ describe('Agent Management contract', () => {
instructions: 'Updated',
model_parameters: { temperature: 0.1 },
code_environment_id: null,
git_identity: { name: 'LibreChat Agent', email: 'agent@example.com' },
}),
).toEqual({
instructions: 'Updated',
model_parameters: { temperature: 0.1 },
code_environment_id: null,
git_identity: { name: 'LibreChat Agent', email: 'agent@example.com' },
});
});

View file

@ -277,6 +277,7 @@ export function projectAgentManagementResponse(
stateful_code_sessions: source.stateful_code_sessions,
stateful_code_environment: source.stateful_code_environment,
code_environment_id: source.code_environment_id,
git_identity: source.git_identity,
artifacts: source.artifacts,
recursion_limit: source.recursion_limit,
conversation_starters: source.conversation_starters,

View file

@ -15,6 +15,32 @@ import {
validateAgentModel,
} from './validation';
describe('agent Git identity validation', () => {
const base = { provider: 'openAI', model: 'gpt-4o-mini', tools: [] };
it('accepts and trims a valid commit identity', () => {
expect(
agentCreateSchema.parse({
...base,
git_identity: { name: ' Coding Agent ', email: ' agent@example.com ' },
}).git_identity,
).toEqual({ name: 'Coding Agent', email: 'agent@example.com' });
});
it.each([
{ name: '', email: 'agent@example.com' },
{ name: 'Coding Agent\nInjected', email: 'agent@example.com' },
{ name: 'Coding Agent', email: 'not-an-email' },
])('rejects an unsafe or incomplete identity: %j', (git_identity) => {
expect(agentCreateSchema.safeParse({ ...base, git_identity }).success).toBe(false);
});
it('accepts null only when updating to clear a configured identity', () => {
expect(agentCreateSchema.safeParse({ ...base, git_identity: null }).success).toBe(false);
expect(agentUpdateSchema.parse({ git_identity: null })).toEqual({ git_identity: null });
});
});
describe('agentSubagentsSchema', () => {
const graph = {
type: 'research_team',

View file

@ -3,13 +3,19 @@ import {
MemoryScope,
SkillsScope,
getMaxSubagents,
agentGitIdentitySchema,
resolveModelCatalogKey,
ViolationTypes,
ErrorTypes,
MAX_SUBAGENT_GRAPH_NODES,
MAX_GRAPH_SUBAGENT_MEMBERS,
} from 'librechat-data-provider';
import type { Agent, TModelsConfig, AgentSubagentsConfig } from 'librechat-data-provider';
import type {
Agent,
AgentGitIdentity,
TModelsConfig,
AgentSubagentsConfig,
} from 'librechat-data-provider';
import type { Request, Response } from 'express';
/**
@ -402,6 +408,8 @@ export const agentSubagentsSchema: z.ZodOptional<z.ZodType<AgentSubagentsConfig>
/** Base agent schema with all common fields */
const agentCodeEnvironmentIdSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/);
const agentGitIdentityUpdateSchema: z.ZodType<AgentGitIdentity | null | undefined> =
agentGitIdentitySchema.nullable();
export const agentBaseSchema: z.ZodObject<
{
@ -492,6 +500,7 @@ export const agentBaseSchema: z.ZodObject<
stateful_code_sessions: z.ZodOptional<z.ZodBoolean>;
stateful_code_environment: z.ZodOptional<z.ZodEnum<['user', 'agent-user', 'conversation']>>;
code_environment_id: z.ZodOptional<z.ZodString>;
git_identity: typeof agentGitIdentitySchema;
artifacts: z.ZodOptional<z.ZodString>;
recursion_limit: z.ZodOptional<z.ZodNumber>;
conversation_starters: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
@ -567,6 +576,7 @@ export const agentBaseSchema: z.ZodObject<
stateful_code_sessions: z.boolean().optional(),
stateful_code_environment: z.enum(['user', 'agent-user', 'conversation']).optional(),
code_environment_id: agentCodeEnvironmentIdSchema.optional(),
git_identity: agentGitIdentitySchema,
artifacts: z.string().optional(),
recursion_limit: z.number().optional(),
conversation_starters: z.array(z.string()).optional(),
@ -665,6 +675,7 @@ export const agentCreateSchema: z.ZodObject<
stateful_code_sessions: z.ZodOptional<z.ZodBoolean>;
stateful_code_environment: z.ZodOptional<z.ZodEnum<['user', 'agent-user', 'conversation']>>;
code_environment_id: z.ZodOptional<z.ZodString>;
git_identity: typeof agentGitIdentitySchema;
artifacts: z.ZodOptional<z.ZodString>;
recursion_limit: z.ZodOptional<z.ZodNumber>;
conversation_starters: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
@ -799,6 +810,7 @@ export const agentUpdateSchema: z.ZodObject<
stateful_code_sessions: z.ZodOptional<z.ZodBoolean>;
stateful_code_environment: z.ZodOptional<z.ZodEnum<['user', 'agent-user', 'conversation']>>;
code_environment_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
git_identity: typeof agentGitIdentityUpdateSchema;
artifacts: z.ZodOptional<z.ZodString>;
recursion_limit: z.ZodOptional<z.ZodNumber>;
conversation_starters: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
@ -883,6 +895,7 @@ export const agentUpdateSchema: z.ZodObject<
> = agentBaseSchema.extend({
avatar: z.union([agentAvatarSchema, z.null()]).optional(),
code_environment_id: agentCodeEnvironmentIdSchema.nullable().optional(),
git_identity: agentGitIdentityUpdateSchema,
provider: z.string().optional(),
model: z.string().nullable().optional(),
});

View file

@ -1,5 +1,50 @@
import { once } from 'node:events';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { CodeBridgeFetch } from './bridge';
import { ATTACHED_WORKSPACE_BASH_SCHEMA, createAttachedWorkspaceBashTool } from './command';
import {
ATTACHED_WORKSPACE_BASH_SCHEMA,
createAttachedWorkspaceBashTool,
createGitIdentityProgrammaticBashTool,
} from './command';
describe('programmatic Bash Git identity', () => {
test('applies authorship before the SDK sends a programmatic script', async () => {
let receivedCode = '';
const server = createServer(async (req, res) => {
let body = '';
for await (const chunk of req) body += chunk;
receivedCode = JSON.parse(body).code;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ status: 'completed', stdout: 'done', stderr: '', files: [] }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address() as AddressInfo;
const bashTool = createGitIdentityProgrammaticBashTool(
{ baseUrl: `http://127.0.0.1:${port}/v1`, authHeaders: () => ({}) },
{ name: "Agent O'Brien", email: 'agent@example.com' },
);
try {
const invocationConfig = {
tags: [],
toolCall: { toolDefs: [] },
};
await bashTool.func(
{ code: 'git commit -m feature', tool_manifest: [] },
undefined,
invocationConfig,
);
expect(receivedCode).toContain(`GIT_AUTHOR_NAME='Agent O'"'"'Brien'`);
expect(receivedCode).toContain("GIT_COMMITTER_EMAIL='agent@example.com'");
expect(receivedCode).toContain('git commit -m feature');
expect(receivedCode).not.toContain('git config');
} finally {
server.close();
await once(server, 'close');
}
});
});
function commandResponse(overrides: Record<string, unknown> = {}): Response {
return new Response(
@ -86,6 +131,24 @@ describe('createAttachedWorkspaceBashTool', () => {
expect(request.command).toBe(`bash -c 'printf "%s" "$1"' -- 'a'"'"'b; echo unsafe'`);
});
test('injects the configured agent Git identity without writing machine Git configuration', async () => {
const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse());
const bashTool = createAttachedWorkspaceBashTool({
baseUrl: 'https://code.example.com/v1',
authHeaders: () => ({}),
gitIdentity: { name: "Agent O'Brien", email: 'agent@example.com' },
fetchImpl,
});
await bashTool.func({ command: 'git commit -m "Implement feature"' }, undefined, {});
const request = JSON.parse(String((fetchImpl as jest.Mock).mock.calls[0][1]?.body));
expect(request.command).toBe(
`export GIT_AUTHOR_NAME='Agent O'"'"'Brien' GIT_AUTHOR_EMAIL='agent@example.com' GIT_COMMITTER_NAME='Agent O'"'"'Brien' GIT_COMMITTER_EMAIL='agent@example.com'; git commit -m "Implement feature"`,
);
expect(request.command).not.toContain('git config');
});
test('reports termination, timeouts, and truncation without hiding stderr', async () => {
const fetchImpl: CodeBridgeFetch = jest.fn(async () =>
commandResponse({

View file

@ -1,6 +1,11 @@
import { tool } from '@librechat/agents/langchain/tools';
import { BashExecutionToolDefinition, BashToolOutputReferencesGuide } from '@librechat/agents';
import {
BashExecutionToolDefinition,
BashToolOutputReferencesGuide,
createBashProgrammaticToolCallingTool,
} from '@librechat/agents';
import type { DynamicStructuredTool } from '@librechat/agents/langchain/tools';
import type { AgentGitIdentity } from 'librechat-data-provider';
import type { LCTool } from '@librechat/agents';
import type { WorkspaceExecuteCommandResult } from './workspace';
import type { CodeBridgeFetch } from './bridge';
@ -59,6 +64,41 @@ function commandWithArguments(command: string, args: string[] | undefined): stri
return `bash -c ${quoteShellArgument(command)} -- ${args.map(quoteShellArgument).join(' ')}`;
}
function commandWithGitIdentity(
command: string,
identity: AgentGitIdentity | null | undefined,
): string {
if (identity == null) return command;
const name = identity.name.trim();
const email = identity.email.trim();
if (
name.length === 0 ||
name.length > 128 ||
email.length === 0 ||
email.length > 254 ||
/[\0\r\n]/.test(name) ||
/[\0\r\n]/.test(email)
) {
throw new Error('Invalid agent Git identity');
}
return `export GIT_AUTHOR_NAME=${quoteShellArgument(name)} GIT_AUTHOR_EMAIL=${quoteShellArgument(email)} GIT_COMMITTER_NAME=${quoteShellArgument(name)} GIT_COMMITTER_EMAIL=${quoteShellArgument(email)}; ${command}`;
}
/** Apply authorship before the SDK prepares the script and its replay requests. */
export function createGitIdentityProgrammaticBashTool(
options: Parameters<typeof createBashProgrammaticToolCallingTool>[0],
identity?: AgentGitIdentity | null,
): DynamicStructuredTool {
const bashTool = createBashProgrammaticToolCallingTool(options);
if (identity == null) return bashTool;
const execute = bashTool.func.bind(bashTool);
bashTool.func = (input, ...args) => {
const params = input as { code: string };
return execute({ ...params, code: commandWithGitIdentity(params.code, identity) }, ...args);
};
return bashTool;
}
function formatCommandResult(result: WorkspaceExecuteCommandResult): string {
let output = '';
if (result.stdout.length > 0) output += `stdout:\n${result.stdout}\n`;
@ -75,11 +115,13 @@ export function createAttachedWorkspaceBashTool({
baseUrl,
authHeaders,
workspaceId = DEFAULT_WORKSPACE_ID,
gitIdentity,
fetchImpl,
}: {
baseUrl: string;
authHeaders: () => Promise<Record<string, string>> | Record<string, string>;
workspaceId?: string;
gitIdentity?: AgentGitIdentity | null;
fetchImpl?: CodeBridgeFetch;
}): DynamicStructuredTool {
return tool(
@ -87,7 +129,10 @@ export function createAttachedWorkspaceBashTool({
rawInput: { command: string; args?: string[]; intent?: string },
config,
): Promise<[string, Record<string, never>]> => {
const command = commandWithArguments(rawInput.command, rawInput.args);
const command = commandWithGitIdentity(
commandWithArguments(rawInput.command, rawInput.args),
gitIdentity,
);
const result = await executeWorkspaceTool({
baseURL: baseUrl,
authHeaders: await authHeaders(),

View file

@ -1,3 +1,4 @@
import { z } from 'zod';
import type { OpenAPIV3 } from 'openapi-types';
import type { AssistantsEndpoint, AgentProvider, MemoryScope, SkillsScope } from 'src/schemas';
import type { StatefulCodeEnvironment } from '../stateful-code';
@ -307,6 +308,30 @@ export type AgentSubagentsConfig = {
graphs?: AgentSubagentGraph[];
};
export type AgentGitIdentity = {
/** Commit author and committer display name. */
name: string;
/** Commit author and committer email address. */
email: string;
};
export const agentGitIdentitySchema: z.ZodType<AgentGitIdentity | undefined> = z
.object({
name: z
.string()
.trim()
.min(1)
.max(128)
.refine((value) => !/[\0\r\n]/.test(value)),
email: z
.string()
.trim()
.email()
.max(254)
.refine((value) => !/[\0\r\n]/.test(value)),
})
.optional();
export type Agent = {
_id?: string;
id: string;
@ -339,6 +364,8 @@ export type Agent = {
stateful_code_environment?: StatefulCodeEnvironment;
/** Operator-configured managed or attached stateful execution environment. */
code_environment_id?: string | null;
/** Non-secret Git authorship injected into this agent's sandboxed commands. */
git_identity?: AgentGitIdentity | null;
artifacts?: ArtifactModes;
recursion_limit?: number;
isPublic?: boolean;
@ -381,6 +408,7 @@ export type Agent = {
export type TAgentsMap = Record<string, Agent | undefined>;
export type AgentCreateParams = {
git_identity?: AgentGitIdentity;
name?: string | null;
description?: string | null;
avatar?: AgentAvatar | null;
@ -432,6 +460,7 @@ export type AgentUpdateParams = {
| 'stateful_code_sessions'
| 'stateful_code_environment'
| 'code_environment_id'
| 'git_identity'
| 'artifacts'
| 'recursion_limit'
| 'category'

View file

@ -2705,6 +2705,27 @@ describe('Agent Methods', () => {
expect(revertedAgent.code_environment_id).toBeUndefined();
});
test('should clear a Git identity absent from the restored version', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Legacy Git Identity Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
await updateAgent(
{ id: agentId },
{ git_identity: { name: 'Coding Agent', email: 'agent@example.com' } },
);
const revertedAgent = await revertAgentVersion({ id: agentId }, 0);
expect(revertedAgent.git_identity).toBeUndefined();
});
test('should clear skill fields absent from a restored legacy version', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();

View file

@ -1435,7 +1435,12 @@ export function createAgentMethods(
}
const unsetOnRestore: Record<string, 1> = {};
for (const field of ['code_environment_id', 'skills_scope', 'skill_authoring_enabled']) {
for (const field of [
'code_environment_id',
'git_identity',
'skills_scope',
'skill_authoring_enabled',
]) {
if (!Object.prototype.hasOwnProperty.call(revertToVersion, field)) {
unsetOnRestore[field] = 1;
}

View file

@ -94,6 +94,16 @@ const agentSchema: Schema<IAgent> = new Schema<IAgent>(
code_environment_id: {
type: String,
},
git_identity: {
type: new Schema(
{
name: { type: String, required: true },
email: { type: String, required: true },
},
{ _id: false },
),
default: undefined,
},
/** @deprecated Use edges instead */
agent_ids: {
type: [String],

View file

@ -6,6 +6,7 @@ import type {
AgentToolOptions,
AgentToolResources,
AgentSubagentsConfig,
AgentGitIdentity,
} from 'librechat-data-provider';
export interface ISupportContact {
@ -42,6 +43,7 @@ export interface IAgent extends Omit<Document, 'model'> {
stateful_code_sessions?: boolean;
stateful_code_environment?: 'user' | 'agent-user' | 'conversation';
code_environment_id?: string;
git_identity?: AgentGitIdentity | null;
/** @deprecated Use edges instead */
agent_ids?: string[];
edges?: GraphEdge[];