From aeac7a206520d85863264fad5d5302d7256a6cd3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 5 Sep 2026 23:56:23 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=AA=20feat:=20Add=20Per-Agent=20Git=20?= =?UTF-8?q?Identity=20(#15628)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- api/server/controllers/agents/v1.js | 8 + api/server/controllers/agents/v1.spec.js | 15 ++ api/server/services/ToolService.js | 10 +- .../services/__tests__/ToolService.spec.js | 2 + client/src/common/agents-types.ts | 2 + .../Agents/Advanced/StatefulSessions.tsx | 72 ++++++++- .../SidePanel/Agents/AgentPanel.tsx | 21 ++- .../SidePanel/Agents/AgentSelect.tsx | 1 + .../__tests__/AgentPanel.helpers.spec.ts | 52 ++++++- .../__tests__/StatefulSessions.spec.tsx | 144 ++++++++++++++++++ client/src/locales/en/translation.json | 5 + packages/api/src/agents/lazySubagents.spec.ts | 14 ++ packages/api/src/agents/lazySubagents.ts | 3 + packages/api/src/agents/management.spec.ts | 3 + packages/api/src/agents/management.ts | 1 + packages/api/src/agents/validation.spec.ts | 26 ++++ packages/api/src/agents/validation.ts | 15 +- packages/api/src/code/command.spec.ts | 65 +++++++- packages/api/src/code/command.ts | 49 +++++- .../data-provider/src/types/assistants.ts | 29 ++++ .../data-schemas/src/methods/agent.spec.ts | 21 +++ packages/data-schemas/src/methods/agent.ts | 7 +- packages/data-schemas/src/schema/agent.ts | 10 ++ packages/data-schemas/src/types/agent.ts | 2 + 24 files changed, 567 insertions(+), 10 deletions(-) create mode 100644 client/src/components/SidePanel/Agents/__tests__/StatefulSessions.spec.tsx diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index cd455dc67b..2d55ff44cd 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -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 diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 7fc941f83c..91c351f795 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -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 }, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 61fb486acb..2e4f770b7a 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 825ed48356..098bdcff1a 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -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 }); }); diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index 9808f4c973..7bad08b819 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -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[]; diff --git a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx index d0958c0a63..4e21a57880 100644 --- a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx @@ -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(); - 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() {

{localize('com_nav_info_stateful_code_environment')}

+ {showGitIdentity && ( +
+
+ {localize('com_ui_agent_git_identity')} +
+ + + {(errors.git_identity?.name || errors.git_identity?.email) && ( +

+ {localize('com_ui_agent_git_identity_both_required')} +

+ )} +

+ {localize('com_nav_info_agent_git_identity')} +

+
+ )} )} diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 481ed519eb..8d22a32c03 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -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, diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index 27814c6d0c..a586fa8634 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -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]) => { diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts index ced3a9ec36..c2937d6345 100644 --- a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts +++ b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts @@ -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 = []; diff --git a/client/src/components/SidePanel/Agents/__tests__/StatefulSessions.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/StatefulSessions.spec.tsx new file mode 100644 index 0000000000..fc4f5bf991 --- /dev/null +++ b/client/src/components/SidePanel/Agents/__tests__/StatefulSessions.spec.tsx @@ -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({ + mode: 'onChange', + defaultValues: { + execute_code: true, + stateful_code_sessions: true, + git_identity: savedIdentity, + }, + }); + return ( + + {advanced && } + + + + + {JSON.stringify(methods.watch('git_identity'))} + + ); +} + +test.each(['', 'not-an-email'])( + 'restores the saved identity when reopening an invalid draft: %s', + async (email) => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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'); +}); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 42cd94e5ee..93d4245365 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/packages/api/src/agents/lazySubagents.spec.ts b/packages/api/src/agents/lazySubagents.spec.ts index fa841af6e1..320a0235fb 100644 --- a/packages/api/src/agents/lazySubagents.spec.ts +++ b/packages/api/src/agents/lazySubagents.spec.ts @@ -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, diff --git a/packages/api/src/agents/lazySubagents.ts b/packages/api/src/agents/lazySubagents.ts index db16fd08dd..aa24824513 100644 --- a/packages/api/src/agents/lazySubagents.ts +++ b/packages/api/src/agents/lazySubagents.ts @@ -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 { 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' }, }); }); diff --git a/packages/api/src/agents/management.ts b/packages/api/src/agents/management.ts index 10be8e113a..51694e2dec 100644 --- a/packages/api/src/agents/management.ts +++ b/packages/api/src/agents/management.ts @@ -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, diff --git a/packages/api/src/agents/validation.spec.ts b/packages/api/src/agents/validation.spec.ts index cced15f646..f0b83ccc1c 100644 --- a/packages/api/src/agents/validation.spec.ts +++ b/packages/api/src/agents/validation.spec.ts @@ -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', diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts index 27a9956227..e5baf2be8a 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -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 /** 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 = + agentGitIdentitySchema.nullable(); export const agentBaseSchema: z.ZodObject< { @@ -492,6 +500,7 @@ export const agentBaseSchema: z.ZodObject< stateful_code_sessions: z.ZodOptional; stateful_code_environment: z.ZodOptional>; code_environment_id: z.ZodOptional; + git_identity: typeof agentGitIdentitySchema; artifacts: z.ZodOptional; recursion_limit: z.ZodOptional; conversation_starters: z.ZodOptional>; @@ -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; stateful_code_environment: z.ZodOptional>; code_environment_id: z.ZodOptional; + git_identity: typeof agentGitIdentitySchema; artifacts: z.ZodOptional; recursion_limit: z.ZodOptional; conversation_starters: z.ZodOptional>; @@ -799,6 +810,7 @@ export const agentUpdateSchema: z.ZodObject< stateful_code_sessions: z.ZodOptional; stateful_code_environment: z.ZodOptional>; code_environment_id: z.ZodOptional>; + git_identity: typeof agentGitIdentityUpdateSchema; artifacts: z.ZodOptional; recursion_limit: z.ZodOptional; conversation_starters: z.ZodOptional>; @@ -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(), }); diff --git a/packages/api/src/code/command.spec.ts b/packages/api/src/code/command.spec.ts index 9e29965b24..3247e18240 100644 --- a/packages/api/src/code/command.spec.ts +++ b/packages/api/src/code/command.spec.ts @@ -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 = {}): 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({ diff --git a/packages/api/src/code/command.ts b/packages/api/src/code/command.ts index ff3a9d1330..b6afc18953 100644 --- a/packages/api/src/code/command.ts +++ b/packages/api/src/code/command.ts @@ -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[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; 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]> => { - 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(), diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index 66ba84d6b1..672d262834 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -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 = 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; 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' diff --git a/packages/data-schemas/src/methods/agent.spec.ts b/packages/data-schemas/src/methods/agent.spec.ts index b54dcd0b25..a371438ea1 100644 --- a/packages/data-schemas/src/methods/agent.spec.ts +++ b/packages/data-schemas/src/methods/agent.spec.ts @@ -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(); diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index 10fba14005..4f2d392221 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -1435,7 +1435,12 @@ export function createAgentMethods( } const unsetOnRestore: Record = {}; - 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; } diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index 4e9c701294..77e8792e5d 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -94,6 +94,16 @@ const agentSchema: Schema = new Schema( 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], diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts index deeedc6c21..8e55020542 100644 --- a/packages/data-schemas/src/types/agent.ts +++ b/packages/data-schemas/src/types/agent.ts @@ -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 { 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[];