diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js
index 6994c106d3a..3afc2ba13ef 100644
--- a/api/server/services/ToolService.js
+++ b/api/server/services/ToolService.js
@@ -2280,6 +2280,7 @@ async function loadToolsForExecution({
authHeaders,
baseUrl: codeExecutionContext.baseUrl,
workspaceId: codeExecutionContext.codeWorkspace.workspaceId,
+ environment: codeExecutionContext.codeWorkspace.environment,
gitIdentity: agent?.git_identity,
maxTimeoutMs: resolveAttachedWorkspaceCommandTimeoutMax(
codeExecutionContext.codeEnvironmentConfigSchema,
diff --git a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
index d71265f96c8..783f1f00a2e 100644
--- a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
+++ b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
@@ -123,6 +123,13 @@ function EnvironmentWorkspaces({
{descriptor.name && (
{descriptor.id}
)}
+ {(descriptor.environment?.repo || descriptor.environment?.ref) && (
+
+ {[descriptor.environment.repo, descriptor.environment.ref]
+ .filter(Boolean)
+ .join(' · ')}
+
+ )}
{selected && (
diff --git a/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx b/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx
index 29b31609f0b..40934c0e304 100644
--- a/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx
+++ b/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx
@@ -88,6 +88,25 @@ function renderMenu(ui: React.ReactElement) {
}
describe('CodeWorkspaceMenu', () => {
+ test.each([
+ ['example/app', 'example/app · dev'],
+ [undefined, 'dev'],
+ ])('shows project metadata without changing selection (%s)', async (repo, label) => {
+ const state = workspace();
+ state.environments[0].workspaces[0].environment = {
+ fingerprint: 'a'.repeat(64),
+ repo,
+ ref: 'dev',
+ actions: ['typecheck'],
+ };
+ const setConversation = jest.fn();
+ renderMenu(
+ ,
+ );
+ await userEvent.click(screen.getByTestId('code-workspace'));
+ expect(await screen.findByText(label!)).toBeInTheDocument();
+ expect(setConversation).not.toHaveBeenCalled();
+ });
test('shows a suggested workspace without committing the conversation decision', () => {
const setConversation = jest.fn();
renderMenu(
diff --git a/client/src/components/Chat/approval/__tests__/preview.test.ts b/client/src/components/Chat/approval/__tests__/preview.test.ts
index 77111f9abc3..a20c8b1c26b 100644
--- a/client/src/components/Chat/approval/__tests__/preview.test.ts
+++ b/client/src/components/Chat/approval/__tests__/preview.test.ts
@@ -1,6 +1,16 @@
import { buildApprovalPreview, buildApprovalPreviews } from '../preview';
describe('buildApprovalPreview', () => {
+ test('shows named action arguments instead of an empty command', () => {
+ const preview = buildApprovalPreview({
+ name: 'bash_tool',
+ source: 'librechat_code',
+ tool_call_id: 'action-1',
+ arguments: { environmentAction: 'typecheck', timeoutMs: 120000 },
+ });
+ expect(preview.kind).toBe('generic');
+ expect(JSON.parse(preview.body)).toEqual({ environmentAction: 'typecheck', timeoutMs: 120000 });
+ });
test('shows the exact effective command and reveals bidi control characters', () => {
const preview = buildApprovalPreview({
name: 'bash_tool',
diff --git a/client/src/components/Chat/approval/preview.ts b/client/src/components/Chat/approval/preview.ts
index 23652e1051b..f5008a23ee1 100644
--- a/client/src/components/Chat/approval/preview.ts
+++ b/client/src/components/Chat/approval/preview.ts
@@ -116,7 +116,12 @@ export function buildApprovalPreview(
let target: string | undefined;
let rawBody = stringifyArguments(request.arguments);
- if (request.source === 'librechat_code' && request.name === 'bash_tool' && parsed) {
+ if (
+ request.source === 'librechat_code' &&
+ request.name === 'bash_tool' &&
+ parsed &&
+ parsed.environmentAction === undefined
+ ) {
kind = 'command';
rawBody = stringField(parsed, 'command');
} else if (request.source === 'librechat_code' && request.name === 'create_file' && parsed) {
diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts
index 51ea605bab7..c67e45f03d3 100644
--- a/packages/api/src/agents/__tests__/initialize.test.ts
+++ b/packages/api/src/agents/__tests__/initialize.test.ts
@@ -2910,6 +2910,7 @@ describe('initializeAgent — execute_code capability expansion', () => {
environmentId: 'personal-vm',
workspaceId: 'project-a',
operations: ['read_file', 'list_files', 'execute_command'],
+ environment: { fingerprint: 'a'.repeat(64), repo: 'owner/project', actions: ['check'] },
},
};
if (protectedEdit) codeExecutionContext.codeWorkspace!.operations.push('edit_file');
@@ -2944,6 +2945,11 @@ describe('initializeAgent — execute_code capability expansion', () => {
'read_file',
]);
const bashTool = result.toolDefinitions?.find(({ name }) => name === 'bash_tool');
+ expect(bashTool?.parameters).toMatchObject({
+ properties: { environmentAction: { enum: ['check'] } },
+ required: [],
+ });
+ expect(bashTool?.description).toContain('owner/project');
expect(
(bashTool?.parameters as { properties?: { timeoutMs?: { maximum?: number } } })?.properties
?.timeoutMs?.maximum,
diff --git a/packages/api/src/agents/execution.spec.ts b/packages/api/src/agents/execution.spec.ts
index d281dbd4d2e..376da3583ac 100644
--- a/packages/api/src/agents/execution.spec.ts
+++ b/packages/api/src/agents/execution.spec.ts
@@ -391,6 +391,23 @@ describe('stateful code approval target binding', () => {
).not.toEqual(binding(original));
});
+ it('requires new approval when an environment action definition changes', () => {
+ const original = context();
+ original.codeWorkspace!.environment = { fingerprint: 'a'.repeat(64), actions: ['typecheck'] };
+ const expected = captureCodeExecutionApprovalBinding([
+ { id: 'a', codeExecutionContext: original },
+ ]);
+ const updated = context({
+ codeWorkspace: {
+ ...original.codeWorkspace!,
+ environment: { fingerprint: 'b'.repeat(64), actions: ['typecheck'] },
+ },
+ });
+ expect(() =>
+ assertCodeExecutionApprovalBinding(expected, [{ id: 'a', codeExecutionContext: updated }]),
+ ).toThrow('changed while this action awaited approval');
+ });
+
it('captures only opaque, canonical identities for stateful targets', () => {
const binding = captureCodeExecutionApprovalBinding([
{
diff --git a/packages/api/src/agents/execution.ts b/packages/api/src/agents/execution.ts
index 34473935407..65e01681766 100644
--- a/packages/api/src/agents/execution.ts
+++ b/packages/api/src/agents/execution.ts
@@ -4,6 +4,7 @@ import { Constants, getCodeBaseURL } from '@librechat/agents';
import type {
Agents,
CodeWorkspaceOperation,
+ CodeWorkspaceDescriptor,
CodeWorkspaceSelection,
CodeEnvironmentUserConfigSchema,
CodeEnvironmentUserSettings,
@@ -38,7 +39,10 @@ export interface CodeExecutionContext {
codeEnvironmentConfigSchema?: CodeEnvironmentUserConfigSchema;
codeEnvironmentSettings?: CodeEnvironmentUserSettings;
/** Live, server-validated directory selection. Never derive session reuse from this field. */
- codeWorkspace?: CodeWorkspaceSelection & { operations: CodeWorkspaceOperation[] };
+ codeWorkspace?: CodeWorkspaceSelection & {
+ operations: CodeWorkspaceOperation[];
+ environment?: CodeWorkspaceDescriptor['environment'];
+ };
}
/** Removes live capability data before a workspace binding is persisted. */
@@ -109,6 +113,9 @@ export function captureCodeExecutionApprovalBinding(
environmentId: context.codeWorkspace.environmentId,
workspaceId: context.codeWorkspace.workspaceId,
operations: [...new Set(context.codeWorkspace.operations)].sort(),
+ ...(context.codeWorkspace.environment
+ ? { definitionFingerprint: context.codeWorkspace.environment.fingerprint }
+ : {}),
},
]),
)
diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts
index f36ffc49f59..52ab19d2790 100644
--- a/packages/api/src/agents/initialize.ts
+++ b/packages/api/src/agents/initialize.ts
@@ -2038,6 +2038,7 @@ export async function initializeAgent(
workspaceTools: attachedWorkspaceTools,
workspaceOperations: attachedWorkspaceOperations,
workspaceCommandTimeoutMaxMs: attachedWorkspaceCommandTimeoutMaxMs,
+ workspaceEnvironment: trustedCodeExecutionContext.codeWorkspace?.environment,
});
toolDefinitions = codeExecResult.toolDefinitions;
recordCapabilityToolNames(AgentCapabilities.execute_code, codeExecResult.toolNames);
@@ -2255,6 +2256,8 @@ export async function initializeAgent(
workspaceTools: attachedWorkspaceTools,
workspaceOperations: attachedWorkspaceOperations,
userId: user?.id,
+ workspaceCommandTimeoutMaxMs: attachedWorkspaceCommandTimeoutMaxMs,
+ workspaceEnvironment: trustedCodeExecutionContext.codeWorkspace?.environment,
skillStates: params.skillStates,
defaultActiveOnShare: params.defaultActiveOnShare,
maxCatalogSkills: getMaxCatalogSkills(runtime),
diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts
index 9c7367f6095..beb5373b72a 100644
--- a/packages/api/src/agents/skills.ts
+++ b/packages/api/src/agents/skills.ts
@@ -2,8 +2,12 @@ import { logger } from '@librechat/data-schemas';
import { HumanMessage } from '@librechat/agents/langchain/messages';
import { SkillsScope, isEphemeralAgentId, resolveAgentSkillsScope } from 'librechat-data-provider';
import { formatSkillCatalog, SkillToolDefinition, ReadFileToolDefinition } from '@librechat/agents';
+import type {
+ Agent,
+ CodeWorkspaceOperation,
+ CodeWorkspaceDescriptor,
+} from 'librechat-data-provider';
import type { LCToolRegistry, LCTool, InjectedMessage } from '@librechat/agents';
-import type { Agent, CodeWorkspaceOperation } from 'librechat-data-provider';
import type { BaseMessage } from '@librechat/agents/langchain/messages';
import type { Types } from 'mongoose';
import { createSkillContentDigest } from './compatibility';
@@ -436,6 +440,7 @@ export interface InjectSkillCatalogParams {
workspaceOperations?: ReadonlySet;
/** Deployment ceiling advertised on attached Bash tool definitions. */
workspaceCommandTimeoutMaxMs?: number;
+ workspaceEnvironment?: CodeWorkspaceDescriptor['environment'];
/** Current user ID — used to determine skill ownership for active-state resolution. */
userId?: string;
/** Per-user skill overrides: `{ [skillId]: boolean }`. Missing entries use the default. */
@@ -665,6 +670,7 @@ export async function injectSkillCatalog(
workspaceTools,
workspaceOperations,
workspaceCommandTimeoutMaxMs,
+ workspaceEnvironment,
userId,
skillStates,
defaultActiveOnShare = false,
@@ -825,6 +831,7 @@ export async function injectSkillCatalog(
workspaceTools: workspaceTools === true,
workspaceOperations,
workspaceCommandTimeoutMaxMs,
+ workspaceEnvironment,
});
workingDefs = codeExecResult.toolDefinitions;
diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts
index a49d61aaac8..d21683a668d 100644
--- a/packages/api/src/agents/tools.spec.ts
+++ b/packages/api/src/agents/tools.spec.ts
@@ -431,6 +431,27 @@ describe('buildHistoricalToolNames', () => {
});
describe('registerCodeExecutionTools', () => {
+ it('advertises selected named actions to the model', () => {
+ const result = registerCodeExecutionTools({
+ toolRegistry: undefined,
+ toolDefinitions: [],
+ includeBash: true,
+ workspaceTools: true,
+ workspaceOperations: new Set(['execute_command']),
+ workspaceEnvironment: {
+ fingerprint: 'a'.repeat(64),
+ repo: 'owner/app',
+ ref: 'main',
+ actions: ['check'],
+ },
+ });
+ const bash = result.toolDefinitions.find((def) => def.name === 'bash_tool');
+ expect(bash?.parameters).toMatchObject({
+ required: [],
+ properties: { environmentAction: { enum: ['check'] } },
+ });
+ expect(bash?.description).toContain('owner/app');
+ });
const makeRegistry = (): LCToolRegistry => new Map() as unknown as LCToolRegistry;
describe('fresh run (no pre-existing defs or registry entries)', () => {
diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts
index b2a651ae2be..08f4091d29e 100644
--- a/packages/api/src/agents/tools.ts
+++ b/packages/api/src/agents/tools.ts
@@ -12,7 +12,12 @@ import {
ReadFileToolDefinition,
buildBashExecutionToolDescription,
} from '@librechat/agents';
-import type { AgentToolOptions, CodeWorkspaceOperation, GraphEdge } from 'librechat-data-provider';
+import type {
+ AgentToolOptions,
+ CodeWorkspaceOperation,
+ CodeWorkspaceDescriptor,
+ GraphEdge,
+} from 'librechat-data-provider';
import type { LCTool, LCToolRegistry } from '@librechat/agents';
import type { ReachableAgent } from './traversal';
import {
@@ -406,6 +411,7 @@ export interface RegisterCodeExecutionToolsParams {
workspaceOperations?: ReadonlySet;
/** Deployment ceiling advertised on attached Bash tool definitions. */
workspaceCommandTimeoutMaxMs?: number;
+ workspaceEnvironment?: CodeWorkspaceDescriptor['environment'];
/**
* When `true`, the registered `bash_tool` description includes the
* LLM-facing `{{toolturn}}` reference syntax guide so the
@@ -916,6 +922,7 @@ function createBashToolDef(
statefulSessions = false,
workspaceTools = false,
workspaceCommandTimeoutMaxMs?: number,
+ workspaceEnvironment?: CodeWorkspaceDescriptor['environment'],
): LCTool {
/* Passed as a variable (not an inline literal) so the extra
* `statefulSessions` key stays assignable against pinned SDK versions
@@ -925,10 +932,10 @@ function createBashToolDef(
name: BashExecutionToolDefinition.name,
toolType: 'builtin',
description: workspaceTools
- ? buildAttachedWorkspaceBashDescription(enableToolOutputReferences)
+ ? buildAttachedWorkspaceBashDescription(enableToolOutputReferences, workspaceEnvironment)
: buildBashExecutionToolDescription(descriptionOpts),
parameters: (workspaceTools
- ? buildAttachedWorkspaceBashSchema(workspaceCommandTimeoutMaxMs)
+ ? buildAttachedWorkspaceBashSchema(workspaceCommandTimeoutMaxMs, workspaceEnvironment)
: BashExecutionToolDefinition.schema) as unknown as LCTool['parameters'],
}) as LCTool;
}
@@ -941,6 +948,7 @@ function buildBashToolDef(opts: {
statefulSessions?: boolean;
workspaceTools?: boolean;
workspaceCommandTimeoutMaxMs?: number;
+ workspaceEnvironment?: CodeWorkspaceDescriptor['environment'];
}): LCTool {
/* Stateful defs are built on demand: the stateless pair covers the
* default path, and per-run construction is negligible next to init. */
@@ -950,6 +958,7 @@ function buildBashToolDef(opts: {
opts.statefulSessions === true,
opts.workspaceTools === true,
opts.workspaceCommandTimeoutMaxMs,
+ opts.workspaceEnvironment,
);
}
return opts.enableToolOutputReferences
@@ -982,6 +991,7 @@ export function registerCodeExecutionTools(
workspaceTools = false,
workspaceOperations,
workspaceCommandTimeoutMaxMs,
+ workspaceEnvironment,
enableToolOutputReferences = false,
statefulSessions = false,
} = params;
@@ -1001,6 +1011,7 @@ export function registerCodeExecutionTools(
statefulSessions,
workspaceTools,
workspaceCommandTimeoutMaxMs,
+ workspaceEnvironment,
}),
);
}
diff --git a/packages/api/src/code/bridge.spec.ts b/packages/api/src/code/bridge.spec.ts
index 1e7e78c9ffe..36e1494c926 100644
--- a/packages/api/src/code/bridge.spec.ts
+++ b/packages/api/src/code/bridge.spec.ts
@@ -5,6 +5,40 @@ import {
} from './bridge';
describe('getCodeBridgeWorkerStatus', () => {
+ test('accepts the maximum declared environment metadata population', async () => {
+ const workspaces = Array.from({ length: 32 }, (_, index) => ({
+ id: `root-${index}`,
+ name: 'n'.repeat(128),
+ environment: {
+ fingerprint: 'a'.repeat(64),
+ repo: `a/${'b'.repeat(254)}`,
+ ref: '\t'.repeat(255) + 'x',
+ actions: Array.from({ length: 32 }, (_, action) => `${action}${'a'.repeat(62)}`),
+ },
+ }));
+ const payload = {
+ protocolVersion: 1,
+ workerId: 'personal-vm',
+ online: true,
+ ready: true,
+ leaseExpiresInMs: 45000,
+ capabilities: {
+ statefulWorkspace: false,
+ sandboxProfile: 'native-srt',
+ runtimes: [],
+ workspaceTools: { protocolVersion: 1, operations: ['execute_command'], workspaces },
+ },
+ };
+ expect(Buffer.byteLength(JSON.stringify(payload))).toBeGreaterThan(64 * 1024);
+ await expect(
+ getCodeBridgeWorkerStatus({
+ baseURL: 'https://code.example.com/v1',
+ token: 'token',
+ workerId: 'personal-vm',
+ fetchImpl: jest.fn().mockResolvedValue(Response.json(payload)),
+ }),
+ ).resolves.toMatchObject({ status: 'ready', workspaces });
+ });
test('normalizes a ready worker while exposing only bounded capability metadata', async () => {
const fetchImpl = jest.fn().mockResolvedValue(
new Response(
@@ -171,10 +205,10 @@ describe('getCodeBridgeWorkerStatus', () => {
);
});
- test('rejects an upstream response before buffering more than 64 KiB', async () => {
+ test('rejects an upstream response before buffering more than 256 KiB', async () => {
const fetchImpl = jest
.fn()
- .mockResolvedValue(new Response(JSON.stringify({ ignored: 'x'.repeat(65 * 1024) })));
+ .mockResolvedValue(new Response(JSON.stringify({ ignored: 'x'.repeat(257 * 1024) })));
await expect(
getCodeBridgeWorkerStatus({
diff --git a/packages/api/src/code/bridge.ts b/packages/api/src/code/bridge.ts
index 0ad24ad4cbb..6b5dd940d21 100644
--- a/packages/api/src/code/bridge.ts
+++ b/packages/api/src/code/bridge.ts
@@ -3,11 +3,13 @@ import {
CODE_WORKSPACE_ID_PATTERN,
CODE_WORKSPACE_MAX_COUNT,
CODE_WORKSPACE_OPERATIONS,
+ isCodeWorkspaceEnvironment,
} from 'librechat-data-provider';
import type { CodeWorkspaceDescriptor, CodeWorkspaceOperation } from 'librechat-data-provider';
const CODE_BRIDGE_REQUEST_TIMEOUT_MS = 10_000;
-const CODE_BRIDGE_STATUS_RESPONSE_MAX_BYTES = 64 * 1024;
+// Covers 32 roots with 32 bounded action names and escaped metadata per root.
+const CODE_BRIDGE_STATUS_RESPONSE_MAX_BYTES = 256 * 1024;
export type CodeBridgePrincipalType = 'deployment' | 'tenant' | 'user' | 'role' | 'group';
@@ -183,11 +185,12 @@ function validWorkspaceCapabilities(value: unknown): value is {
const workspace = value as Record;
if (
Object.keys(workspace).some(
- (key) => key !== 'id' && key !== 'name' && key !== 'operations',
+ (key) => key !== 'id' && key !== 'name' && key !== 'operations' && key !== 'environment',
) ||
typeof workspace.id !== 'string' ||
!CODE_WORKSPACE_ID_PATTERN.test(workspace.id) ||
ids.has(workspace.id) ||
+ (workspace.environment !== undefined && !isCodeWorkspaceEnvironment(workspace.environment)) ||
(workspace.name !== undefined &&
(typeof workspace.name !== 'string' ||
workspace.name.trim().length === 0 ||
diff --git a/packages/api/src/code/capabilities.spec.ts b/packages/api/src/code/capabilities.spec.ts
index a820f6cd69d..f9c3056d1a5 100644
--- a/packages/api/src/code/capabilities.spec.ts
+++ b/packages/api/src/code/capabilities.spec.ts
@@ -333,6 +333,28 @@ describe('resolveCodeExecutionWorkspaceContext', () => {
});
});
+ it('carries validated project metadata from the selected workspace', async () => {
+ const environment = {
+ fingerprint: 'a'.repeat(64),
+ repo: 'example/app',
+ ref: 'dev',
+ actions: ['typecheck'],
+ };
+ jest.spyOn(globalThis, 'fetch').mockResolvedValue(
+ workspaceStatus([
+ { id: 'docs', environment },
+ { id: 'other', environment: { ...environment, actions: ['other'] } },
+ ]),
+ );
+ const resolved = await resolveCodeExecutionWorkspaceContext({
+ context,
+ requestedSelections: [{ environmentId: 'personal', workspaceId: 'docs' }],
+ environments,
+ getAppConfig,
+ });
+ expect(resolved.codeWorkspace?.environment).toEqual(environment);
+ });
+
it('admits native workspace tools without enabling programmatic runtime execution', async () => {
jest
.spyOn(globalThis, 'fetch')
diff --git a/packages/api/src/code/capabilities.ts b/packages/api/src/code/capabilities.ts
index 9f049247a82..74a7e40469b 100644
--- a/packages/api/src/code/capabilities.ts
+++ b/packages/api/src/code/capabilities.ts
@@ -178,6 +178,7 @@ export async function resolveCodeExecutionWorkspaceContext({
codeWorkspace: {
...selection,
operations: [...(workspace.operations ?? status.operations)],
+ ...(workspace.environment ? { environment: workspace.environment } : {}),
},
};
}
diff --git a/packages/api/src/code/command.spec.ts b/packages/api/src/code/command.spec.ts
index d265f5a754f..ff542a05c8f 100644
--- a/packages/api/src/code/command.spec.ts
+++ b/packages/api/src/code/command.spec.ts
@@ -66,6 +66,37 @@ function commandResponse(overrides: Record = {}): Response {
}
describe('createAttachedWorkspaceBashTool', () => {
+ test('dispatches only advertised named actions with the resolved definition fingerprint', async () => {
+ const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse());
+ const bashTool = createAttachedWorkspaceBashTool({
+ baseUrl: 'https://code.example.com/v1/',
+ authHeaders: () => ({}),
+ workspaceId: 'project-a',
+ environment: {
+ fingerprint: 'a'.repeat(64),
+ repo: 'example/app',
+ ref: 'main',
+ actions: ['typecheck'],
+ },
+ fetchImpl,
+ });
+ await bashTool.invoke({ environmentAction: 'typecheck' });
+ const [, options] = (fetchImpl as jest.Mock).mock.calls[0];
+ expect(JSON.parse(options.body)).toMatchObject({
+ workspaceId: 'project-a',
+ environmentAction: { name: 'typecheck', fingerprint: 'a'.repeat(64) },
+ timeoutMs: 30000,
+ });
+ for (const input of [
+ { environmentAction: 'missing' },
+ { environmentAction: 'typecheck', command: 'rm x' },
+ { environmentAction: 'typecheck', cwd: 'other' },
+ {},
+ ]) {
+ await expect(bashTool.invoke(input)).rejects.toThrow();
+ }
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ });
test('disconnects the actual HTTP request when an invoked command is cancelled', async () => {
let markStarted!: () => void;
let markDisconnected!: () => void;
diff --git a/packages/api/src/code/command.ts b/packages/api/src/code/command.ts
index 25c3123378a..cd9f6875bfa 100644
--- a/packages/api/src/code/command.ts
+++ b/packages/api/src/code/command.ts
@@ -5,7 +5,11 @@ import {
BashToolOutputReferencesGuide,
createBashProgrammaticToolCallingTool,
} from '@librechat/agents';
-import type { AgentGitIdentity, CodeEnvironmentUserConfigSchema } from 'librechat-data-provider';
+import type {
+ AgentGitIdentity,
+ CodeEnvironmentUserConfigSchema,
+ CodeWorkspaceDescriptor,
+} from 'librechat-data-provider';
import type { DynamicStructuredTool } from '@librechat/agents/langchain/tools';
import type { LCTool } from '@librechat/agents';
import type { WorkspaceExecuteCommandResult } from './workspace';
@@ -89,6 +93,7 @@ export function resolveAttachedWorkspaceCommandTimeoutMax(
export function buildAttachedWorkspaceBashSchema(
maxTimeoutMs: number = WORKSPACE_COMMAND_DEFAULT_TIMEOUT_MS,
+ environment?: CodeWorkspaceDescriptor['environment'],
): NonNullable {
const effectiveMaxTimeoutMs = normalizeAttachedWorkspaceCommandTimeoutMax(maxTimeoutMs);
return {
@@ -98,8 +103,18 @@ export function buildAttachedWorkspaceBashSchema(
command: attachedCommandSchema,
cwd: attachedWorkingDirectorySchema,
timeoutMs: buildAttachedTimeoutSchema(effectiveMaxTimeoutMs),
+ ...(environment?.actions.length
+ ? {
+ environmentAction: {
+ type: 'string',
+ enum: [...environment.actions],
+ description:
+ 'Run a fixed action defined by the machine owner. Supply this instead of command, args or cwd. Normal command approval rules still apply.',
+ },
+ }
+ : {}),
},
- required: ['command'],
+ required: environment?.actions.length ? [] : ['command'],
};
}
@@ -112,10 +127,19 @@ export const ATTACHED_WORKSPACE_BASH_SCHEMA: NonNullable =
buildAttachedWorkspaceBashSchema(),
);
-export function buildAttachedWorkspaceBashDescription(enableToolOutputReferences: boolean): string {
- return enableToolOutputReferences
+export function buildAttachedWorkspaceBashDescription(
+ enableToolOutputReferences: boolean,
+ environment?: CodeWorkspaceDescriptor['environment'],
+): string {
+ const description = enableToolOutputReferences
? `${ATTACHED_WORKSPACE_BASH_DESCRIPTION}\n\n${BashToolOutputReferencesGuide}`
: ATTACHED_WORKSPACE_BASH_DESCRIPTION;
+ return (
+ description +
+ (environment
+ ? `\n\nSelected project metadata (declared by the machine owner): ${JSON.stringify({ repo: environment.repo, ref: environment.ref })}. Named actions use the environmentAction parameter and the same approval rules as commands.`
+ : '')
+ );
}
function quoteShellArgument(value: string): string {
@@ -178,6 +202,7 @@ export function createAttachedWorkspaceBashTool({
baseUrl,
authHeaders,
workspaceId,
+ environment,
gitIdentity,
maxTimeoutMs = WORKSPACE_COMMAND_DEFAULT_TIMEOUT_MS,
fetchImpl,
@@ -185,16 +210,22 @@ export function createAttachedWorkspaceBashTool({
baseUrl: string;
authHeaders: () => Promise> | Record;
workspaceId: string;
+ environment?: CodeWorkspaceDescriptor['environment'];
gitIdentity?: AgentGitIdentity | null;
/** Deployment ceiling already intersected with the protocol hard cap. */
maxTimeoutMs?: number;
fetchImpl?: CodeBridgeFetch;
}): DynamicStructuredTool {
const effectiveMaxTimeoutMs = normalizeAttachedWorkspaceCommandTimeoutMax(maxTimeoutMs);
+ const schema = structuredClone(
+ buildAttachedWorkspaceBashSchema(effectiveMaxTimeoutMs, environment),
+ );
+ const actions = environment?.actions ?? [];
return tool(
async (
rawInput: {
- command: string;
+ command?: string;
+ environmentAction?: string;
args?: string[];
cwd?: string;
timeoutMs?: number;
@@ -202,15 +233,28 @@ export function createAttachedWorkspaceBashTool({
},
config,
): Promise<[string, Record]> => {
+ const action = rawInput.environmentAction;
+ if (action !== undefined) {
+ if (
+ !environment ||
+ !actions.includes(action) ||
+ rawInput.command !== undefined ||
+ rawInput.args !== undefined ||
+ rawInput.cwd !== undefined
+ ) {
+ throw new Error('Choose an advertised environment action without command, args or cwd.');
+ }
+ } else if (typeof rawInput.command !== 'string' || rawInput.command.trim().length === 0) {
+ throw new Error('Supply a command or an advertised environment action.');
+ }
if (rawInput.timeoutMs != null && rawInput.timeoutMs > effectiveMaxTimeoutMs) {
throw new Error(
`Command timeout exceeds the deployment limit of ${effectiveMaxTimeoutMs} milliseconds.`,
);
}
- const command = commandWithGitIdentity(
- commandWithArguments(rawInput.command, rawInput.args),
- gitIdentity,
- );
+ const command =
+ action ??
+ commandWithGitIdentity(commandWithArguments(rawInput.command!, rawInput.args), gitIdentity);
const timeoutMs =
rawInput.timeoutMs ?? Math.min(WORKSPACE_COMMAND_DEFAULT_TIMEOUT_MS, effectiveMaxTimeoutMs);
const signal = config?.signal;
@@ -233,6 +277,9 @@ export function createAttachedWorkspaceBashTool({
operation: 'execute_command',
workspaceId,
command,
+ ...(action && environment
+ ? { environmentAction: { name: action, fingerprint: environment.fingerprint } }
+ : {}),
...(rawInput.cwd ? { cwd: rawInput.cwd } : {}),
timeoutMs,
maxOutputBytes: DEFAULT_OUTPUT_BYTES,
@@ -255,8 +302,8 @@ export function createAttachedWorkspaceBashTool({
},
{
name: BashExecutionToolDefinition.name,
- description: ATTACHED_WORKSPACE_BASH_DESCRIPTION,
- schema: structuredClone(buildAttachedWorkspaceBashSchema(effectiveMaxTimeoutMs)),
+ description: buildAttachedWorkspaceBashDescription(false, environment),
+ schema,
responseFormat: 'content_and_artifact',
},
) as unknown as DynamicStructuredTool;
diff --git a/packages/api/src/code/environment.live.spec.ts b/packages/api/src/code/environment.live.spec.ts
new file mode 100644
index 00000000000..76b948785a3
--- /dev/null
+++ b/packages/api/src/code/environment.live.spec.ts
@@ -0,0 +1,90 @@
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { once } from 'node:events';
+import { spawn } from 'node:child_process';
+import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
+import type { CodeWorkspaceDescriptor } from 'librechat-data-provider';
+import { createAttachedWorkspaceBashTool } from './command';
+import { registerCodeExecutionTools } from '~/agents/tools';
+
+const live = process.env.LIBRECHAT_CODE_TEST_PACKAGE ? describe : describe.skip;
+
+live('native environment integration', () => {
+ test('runs a named action through the LibreChat tool, HTTP and a real native worker', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'lc-environment-live-'));
+ const root = join(directory, 'project');
+ await mkdir(root);
+ const definition = join(directory, 'environment.yaml');
+ await writeFile(
+ definition,
+ 'name: project\nroot: project\nactions:\n - name: verify\n command: "printf verified > result.txt; printf success"\n',
+ );
+ const child = spawn(process.execPath, [join(__dirname, 'fixtures/environment.mjs')], {
+ env: { ...process.env, LIBRECHAT_CODE_TEST_DEFINITION: definition },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ const exited = once(child, 'exit');
+ let errors = '';
+ child.stderr.on('data', (chunk) => {
+ errors += chunk.toString();
+ });
+ try {
+ const started = new Promise<{
+ port: number;
+ environment: CodeWorkspaceDescriptor['environment'];
+ }>((resolve, reject) => {
+ let output = '';
+ child.stdout.on('data', (chunk) => {
+ output += chunk.toString();
+ if (output.includes('\n')) {
+ try {
+ resolve(JSON.parse(output.trim()));
+ } catch (error) {
+ reject(error);
+ }
+ }
+ });
+ });
+ const ready = await Promise.race([
+ started,
+ exited.then(() => {
+ throw new Error(errors);
+ }),
+ ]);
+ const tool = createAttachedWorkspaceBashTool({
+ baseUrl: `http://127.0.0.1:${ready.port}/v1`,
+ authHeaders: () => ({}),
+ workspaceId: 'project',
+ environment: ready.environment,
+ });
+ const definitions = registerCodeExecutionTools({
+ toolRegistry: undefined,
+ toolDefinitions: [],
+ includeBash: true,
+ workspaceTools: true,
+ workspaceOperations: new Set(['execute_command']),
+ workspaceEnvironment: ready.environment,
+ });
+ const modelSchema = definitions.toolDefinitions.find(
+ (definition) => definition.name === 'bash_tool',
+ )?.parameters;
+ expect(modelSchema).toMatchObject({
+ properties: { environmentAction: { enum: ['verify'] } },
+ required: [],
+ });
+ await tool.invoke({ environmentAction: 'verify' });
+ expect(await readFile(join(root, 'result.txt'), 'utf8')).toBe('verified');
+ const stale = createAttachedWorkspaceBashTool({
+ baseUrl: `http://127.0.0.1:${ready.port}/v1`,
+ authHeaders: () => ({}),
+ workspaceId: 'project',
+ environment: { ...ready.environment!, fingerprint: 'b'.repeat(64) },
+ });
+ await expect(stale.invoke({ environmentAction: 'verify' })).rejects.toThrow();
+ } finally {
+ child.kill('SIGTERM');
+ await exited;
+ await rm(directory, { recursive: true, force: true });
+ }
+ }, 20_000);
+});
diff --git a/packages/api/src/code/fixtures/environment.mjs b/packages/api/src/code/fixtures/environment.mjs
new file mode 100644
index 00000000000..a5359d9b710
--- /dev/null
+++ b/packages/api/src/code/fixtures/environment.mjs
@@ -0,0 +1,55 @@
+import { join } from 'node:path';
+import { createServer } from 'node:http';
+import { pathToFileURL } from 'node:url';
+
+const source = process.env.LIBRECHAT_CODE_TEST_PACKAGE;
+const { loadCodeEnvironment, EnvironmentWorkspaceTools } = await import(
+ pathToFileURL(join(source, 'dist/environment.js'))
+);
+const { LocalWorkspaceTools, SandboxWorkspaceTools } = await import(
+ pathToFileURL(join(source, 'dist/workspace.js'))
+);
+const { NativeProcessWorkspaceCommandSandbox } = await import(
+ pathToFileURL(join(source, 'dist/native-process.js'))
+);
+const definition = await loadCodeEnvironment(process.env.LIBRECHAT_CODE_TEST_DEFINITION);
+const id = definition.definition.name;
+const sandbox = new NativeProcessWorkspaceCommandSandbox({
+ workspaceRoot: definition.definition.root,
+});
+await sandbox.prepare();
+const tools = new EnvironmentWorkspaceTools(
+ new SandboxWorkspaceTools({
+ workspaceTools: await LocalWorkspaceTools.create({
+ workspaces: [{ id, root: definition.definition.root }],
+ }),
+ commandWorkspaces: [id],
+ commandSandbox: sandbox,
+ }),
+ [definition],
+);
+const server = createServer(async (request, response) => {
+ try {
+ const chunks = [];
+ for await (const chunk of request) chunks.push(chunk);
+ const result = await tools.execute(JSON.parse(Buffer.concat(chunks).toString()));
+ response.setHeader('Content-Type', 'application/json');
+ response.end(JSON.stringify(result));
+ } catch {
+ response.writeHead(409, { 'Content-Type': 'application/json' });
+ response.end(JSON.stringify({ error: 'Environment action rejected' }));
+ }
+});
+server.listen(0, '127.0.0.1', () =>
+ process.stdout.write(
+ JSON.stringify({
+ port: server.address().port,
+ environment: tools.capabilities.workspaces[0].environment,
+ }) + '\n',
+ ),
+);
+process.on('SIGTERM', async () => {
+ server.closeAllConnections();
+ await sandbox.close();
+ server.close(() => process.exit(0));
+});
diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts
index 900dd6d2112..5deb991b176 100644
--- a/packages/api/src/code/workspace.ts
+++ b/packages/api/src/code/workspace.ts
@@ -126,6 +126,7 @@ export interface WorkspaceExecuteCommandRequest {
cwd?: string;
timeoutMs?: number;
maxOutputBytes?: number;
+ environmentAction?: { name: string; fingerprint: string };
}
export interface WorkspaceWriteRequest {
diff --git a/packages/data-provider/src/code/workspace.ts b/packages/data-provider/src/code/workspace.ts
index 2bc3f2bc6e9..60665cb7cb8 100644
--- a/packages/data-provider/src/code/workspace.ts
+++ b/packages/data-provider/src/code/workspace.ts
@@ -35,6 +35,41 @@ export interface CodeWorkspaceDescriptor {
name?: string;
/** Omitted when every worker-level operation applies to this workspace. */
operations?: CodeWorkspaceOperation[];
+ environment?: {
+ fingerprint: string;
+ repo?: string;
+ ref?: string;
+ actions: string[];
+ };
+}
+
+export function isCodeWorkspaceEnvironment(
+ value: unknown,
+): value is NonNullable {
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) return false;
+ const environment = value as Record;
+ return (
+ Object.keys(environment).every((key) =>
+ ['fingerprint', 'repo', 'ref', 'actions'].includes(key),
+ ) &&
+ typeof environment.fingerprint === 'string' &&
+ /^[a-f0-9]{64}$/.test(environment.fingerprint) &&
+ (environment.repo === undefined ||
+ (typeof environment.repo === 'string' &&
+ environment.repo.length <= 256 &&
+ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(environment.repo))) &&
+ (environment.ref === undefined ||
+ (typeof environment.ref === 'string' &&
+ environment.ref.trim().length > 0 &&
+ environment.ref.length <= 256 &&
+ !/[\0\r\n]/.test(environment.ref))) &&
+ Array.isArray(environment.actions) &&
+ environment.actions.length <= 32 &&
+ environment.actions.every(
+ (name) => typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name),
+ ) &&
+ new Set(environment.actions).size === environment.actions.length
+ );
}
/** Conversation-owned selection, bound to the environment that advertised it. */