Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -2280,6 +2280,7 @@ async function loadToolsForExecution({
authHeaders,
baseUrl: codeExecutionContext.baseUrl,
workspaceId: codeExecutionContext.codeWorkspace.workspaceId,
environment: codeExecutionContext.codeWorkspace.environment,
Comment thread
danny-avila marked this conversation as resolved.
gitIdentity: agent?.git_identity,
maxTimeoutMs: resolveAttachedWorkspaceCommandTimeoutMax(
codeExecutionContext.codeEnvironmentConfigSchema,
Expand Down
7 changes: 7 additions & 0 deletions client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ function EnvironmentWorkspaces({
{descriptor.name && (
<p className="truncate text-xs text-text-secondary">{descriptor.id}</p>
)}
{(descriptor.environment?.repo || descriptor.environment?.ref) && (
<p className="truncate text-xs text-text-secondary">
{[descriptor.environment.repo, descriptor.environment.ref]
.filter(Boolean)
.join(' · ')}
</p>
)}
</div>
{selected && (
<Check className="mt-0.5 size-4 shrink-0 text-text-primary" aria-hidden="true" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CodeWorkspaceMenu setConversation={setConversation} workspace={state} disabled={false} />,
);
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(
Expand Down
10 changes: 10 additions & 0 deletions client/src/components/Chat/approval/__tests__/preview.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
7 changes: 6 additions & 1 deletion client/src/components/Chat/approval/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/agents/__tests__/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions packages/api/src/agents/execution.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
9 changes: 8 additions & 1 deletion packages/api/src/agents/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Constants, getCodeBaseURL } from '@librechat/agents';
import type {
Agents,
CodeWorkspaceOperation,
CodeWorkspaceDescriptor,
CodeWorkspaceSelection,
CodeEnvironmentUserConfigSchema,
CodeEnvironmentUserSettings,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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 }
: {}),
},
]),
)
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/agents/initialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 8 additions & 1 deletion packages/api/src/agents/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -436,6 +440,7 @@ export interface InjectSkillCatalogParams {
workspaceOperations?: ReadonlySet<CodeWorkspaceOperation>;
/** 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. */
Expand Down Expand Up @@ -665,6 +670,7 @@ export async function injectSkillCatalog(
workspaceTools,
workspaceOperations,
workspaceCommandTimeoutMaxMs,
workspaceEnvironment,
userId,
skillStates,
defaultActiveOnShare = false,
Expand Down Expand Up @@ -825,6 +831,7 @@ export async function injectSkillCatalog(
workspaceTools: workspaceTools === true,
workspaceOperations,
workspaceCommandTimeoutMaxMs,
workspaceEnvironment,
});
workingDefs = codeExecResult.toolDefinitions;

Expand Down
21 changes: 21 additions & 0 deletions packages/api/src/agents/tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
17 changes: 14 additions & 3 deletions packages/api/src/agents/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -406,6 +411,7 @@ export interface RegisterCodeExecutionToolsParams {
workspaceOperations?: ReadonlySet<CodeWorkspaceOperation>;
/** Deployment ceiling advertised on attached Bash tool definitions. */
workspaceCommandTimeoutMaxMs?: number;
workspaceEnvironment?: CodeWorkspaceDescriptor['environment'];
/**
* When `true`, the registered `bash_tool` description includes the
* LLM-facing `{{tool<idx>turn<turn>}}` reference syntax guide so the
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand All @@ -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. */
Expand All @@ -950,6 +958,7 @@ function buildBashToolDef(opts: {
opts.statefulSessions === true,
opts.workspaceTools === true,
opts.workspaceCommandTimeoutMaxMs,
opts.workspaceEnvironment,
);
}
return opts.enableToolOutputReferences
Expand Down Expand Up @@ -982,6 +991,7 @@ export function registerCodeExecutionTools(
workspaceTools = false,
workspaceOperations,
workspaceCommandTimeoutMaxMs,
workspaceEnvironment,
enableToolOutputReferences = false,
statefulSessions = false,
} = params;
Expand All @@ -1001,6 +1011,7 @@ export function registerCodeExecutionTools(
statefulSessions,
workspaceTools,
workspaceCommandTimeoutMaxMs,
workspaceEnvironment,
}),
);
}
Expand Down
38 changes: 36 additions & 2 deletions packages/api/src/code/bridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down
7 changes: 5 additions & 2 deletions packages/api/src/code/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -183,11 +185,12 @@ function validWorkspaceCapabilities(value: unknown): value is {
const workspace = value as Record<string, unknown>;
if (
Object.keys(workspace).some(
(key) => key !== 'id' && key !== 'name' && key !== 'operations',
(key) => key !== 'id' && key !== 'name' && key !== 'operations' && key !== 'environment',
) ||
Comment thread
danny-avila marked this conversation as resolved.
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 ||
Expand Down
Loading
Loading