From 6fe50eba60995b769f47c687dfb6adbc5d407db3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 19 Sep 2026 14:46:27 -0400 Subject: [PATCH 1/2] feat: bind attached workspaces to conversations --- api/server/services/Files/Code/process.js | 15 ++++++ .../services/Files/Code/process.spec.js | 4 ++ api/server/services/ToolService.js | 1 + packages/api/src/agents/execution.spec.ts | 45 ++++++++++++++++ packages/api/src/agents/execution.ts | 12 +++++ packages/api/src/agents/handlers.spec.ts | 16 ++++++ packages/api/src/agents/handlers.ts | 19 +++++++ packages/api/src/code/bridge.spec.ts | 27 +++++++++- packages/api/src/code/bridge.ts | 5 ++ packages/api/src/code/capabilities.spec.ts | 28 ++++++++++ packages/api/src/code/capabilities.ts | 4 ++ packages/api/src/code/command.spec.ts | 20 ++++++++ packages/api/src/code/command.ts | 38 +++++++------- packages/api/src/code/instructions.spec.ts | 32 ++++++++++++ packages/api/src/code/instructions.ts | 4 ++ packages/api/src/code/workspace.spec.ts | 51 +++++++++++++++++++ packages/api/src/code/workspace.ts | 11 +++- packages/data-provider/src/code/workspace.ts | 4 ++ 18 files changed, 316 insertions(+), 20 deletions(-) diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 34b4e3f953f..73efab77009 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1112,6 +1112,7 @@ async function readSandboxFile({ * @param {Object} params * @param {string} params.file_path * @param {string} params.workspace_id + * @param {string} [params.workspace_instance_id] * @param {number} params.start_line * @param {number} params.max_lines * @param {string} params.codeApiBaseUrl @@ -1123,6 +1124,7 @@ async function readSandboxFile({ async function readWorkspaceFile({ file_path, workspace_id, + workspace_instance_id, start_line, max_lines, codeApiBaseUrl, @@ -1144,6 +1146,7 @@ async function readWorkspaceFile({ protocolVersion: 1, operation: 'read_file', workspaceId: workspace_id, + ...(workspace_instance_id ? { workspaceInstanceId: workspace_instance_id } : {}), path: file_path, startLine: start_line, maxLines: max_lines, @@ -1158,6 +1161,7 @@ async function readWorkspaceFile({ * @param {Object} params * @param {string} params.query * @param {string} params.workspace_id + * @param {string} [params.workspace_instance_id] * @param {string} [params.path] * @param {number} params.max_results * @param {string} params.codeApiBaseUrl @@ -1169,6 +1173,7 @@ async function readWorkspaceFile({ async function searchWorkspace({ query, workspace_id, + workspace_instance_id, path, max_results, codeApiBaseUrl, @@ -1190,6 +1195,7 @@ async function searchWorkspace({ protocolVersion: 1, operation: 'search_text', workspaceId: workspace_id, + ...(workspace_instance_id ? { workspaceInstanceId: workspace_instance_id } : {}), query, ...(path ? { path } : {}), maxResults: max_results, @@ -1203,6 +1209,7 @@ async function searchWorkspace({ * * @param {Object} params * @param {string} params.workspace_id + * @param {string} [params.workspace_instance_id] * @param {string} [params.path] * @param {string} [params.after_path] * @param {number} params.max_results @@ -1214,6 +1221,7 @@ async function searchWorkspace({ */ async function listWorkspaceFiles({ workspace_id, + workspace_instance_id, path, after_path, max_results, @@ -1236,6 +1244,7 @@ async function listWorkspaceFiles({ protocolVersion: 1, operation: 'list_files', workspaceId: workspace_id, + ...(workspace_instance_id ? { workspaceInstanceId: workspace_instance_id } : {}), ...(path ? { path } : {}), ...(after_path ? { afterPath: after_path } : {}), maxResults: max_results, @@ -1250,6 +1259,7 @@ async function writeWorkspaceFile({ content, overwrite, workspace_id, + workspace_instance_id, codeApiBaseUrl, executionProfile, bridgeWorkerId, @@ -1269,6 +1279,7 @@ async function writeWorkspaceFile({ protocolVersion: 1, operation: 'write_file', workspaceId: workspace_id, + ...(workspace_instance_id ? { workspaceInstanceId: workspace_instance_id } : {}), path: file_path, content, overwrite, @@ -1283,6 +1294,7 @@ async function editWorkspaceFile({ edits, expected_base_sha256, workspace_id, + workspace_instance_id, codeApiBaseUrl, executionProfile, bridgeWorkerId, @@ -1302,6 +1314,7 @@ async function editWorkspaceFile({ protocolVersion: 1, operation: 'edit_file', workspaceId: workspace_id, + ...(workspace_instance_id ? { workspaceInstanceId: workspace_instance_id } : {}), path: file_path, edits, ...(expected_base_sha256 ? { expectedBaseSha256: expected_base_sha256 } : {}), @@ -1315,6 +1328,7 @@ async function previewWorkspaceEdit({ file_path, edits, workspace_id, + workspace_instance_id, codeApiBaseUrl, executionProfile, bridgeWorkerId, @@ -1334,6 +1348,7 @@ async function previewWorkspaceEdit({ protocolVersion: 1, operation: 'preview_edit', workspaceId: workspace_id, + ...(workspace_instance_id ? { workspaceInstanceId: workspace_instance_id } : {}), path: file_path, edits, }, diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 99e3c7464ba..176bf2229b6 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -2050,6 +2050,7 @@ describe('Code Process', () => { readWorkspaceFile({ file_path: 'src/app.ts', workspace_id: 'primary', + workspace_instance_id: 'a'.repeat(64), start_line: 1, max_lines: 200, codeApiBaseUrl: 'https://attached-code.example.com/v1', @@ -2082,6 +2083,7 @@ describe('Code Process', () => { protocolVersion: 1, operation: 'read_file', workspaceId: 'primary', + workspaceInstanceId: 'a'.repeat(64), path: 'src/app.ts', startLine: 1, maxLines: 200, @@ -2227,6 +2229,7 @@ describe('Code Process', () => { content: 'ready', overwrite: false, workspace_id: 'primary', + workspace_instance_id: 'b'.repeat(64), codeApiBaseUrl: 'https://attached-code.example.com/v1', executionProfile: 'stateful', bridgeWorkerId: 'worker-user-1', @@ -2257,6 +2260,7 @@ describe('Code Process', () => { protocolVersion: 1, operation: 'write_file', workspaceId: 'primary', + workspaceInstanceId: 'b'.repeat(64), path: 'src/new.ts', content: 'ready', overwrite: false, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index faf785e2b12..e337a3e9113 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -2296,6 +2296,7 @@ async function loadToolsForExecution({ authHeaders, baseUrl: codeExecutionContext.baseUrl, workspaceId: codeExecutionContext.codeWorkspace.workspaceId, + workspaceInstanceId: codeExecutionContext.codeWorkspace.workspaceInstanceId, environment: codeExecutionContext.codeWorkspace.environment, gitIdentity: agent?.git_identity, maxTimeoutMs: resolveAttachedWorkspaceCommandTimeoutMax( diff --git a/packages/api/src/agents/execution.spec.ts b/packages/api/src/agents/execution.spec.ts index 376da3583ac..bc8b7bacba7 100644 --- a/packages/api/src/agents/execution.spec.ts +++ b/packages/api/src/agents/execution.spec.ts @@ -145,6 +145,33 @@ describe('resolveCodeExecutionContext', () => { expect(context.executionRouteKey).toMatch(/^stateful:[a-f0-9]{32}$/); }); + it('derives a stable opaque worktree identity from authenticated conversation scope', () => { + const resolve = (conversationId: string, userId = 'user-1') => + resolveCodeExecutionContext({ + statefulSessions: true, + environmentId: 'my-vm', + environments: [ + { + id: 'my-vm', + name: 'My VM', + type: 'attached', + baseURL: 'https://bridge.example/v1', + workerId: 'worker', + owner: 'deployment', + }, + ], + userId, + conversationId, + }).conversationWorkspaceInstanceId; + + const first = resolve('conversation-1'); + expect(first).toMatch(/^[a-f0-9]{64}$/); + expect(resolve('conversation-1')).toBe(first); + expect(resolve('conversation-2')).not.toBe(first); + expect(resolve('conversation-1', 'user-2')).not.toBe(first); + expect(first).not.toContain('conversation-1'); + }); + it('routes a deployment worker declared in pairing metadata', () => { const context = resolveCodeExecutionContext({ statefulSessions: true, @@ -408,6 +435,24 @@ describe('stateful code approval target binding', () => { ).toThrow('changed while this action awaited approval'); }); + it('requires new approval when the conversation worktree changes', () => { + const original = context(); + original.codeWorkspace!.workspaceInstanceId = 'a'.repeat(64); + const expected = captureCodeExecutionApprovalBinding([ + { id: 'a', codeExecutionContext: original }, + ]); + const updated = context({ + codeWorkspace: { + ...original.codeWorkspace!, + workspaceInstanceId: 'b'.repeat(64), + }, + }); + + 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 5e55e9ff3b0..93c74c72fc8 100644 --- a/packages/api/src/agents/execution.ts +++ b/packages/api/src/agents/execution.ts @@ -38,9 +38,12 @@ export interface CodeExecutionContext { bridgeWorkerId?: string; codeEnvironmentConfigSchema?: CodeEnvironmentUserConfigSchema; codeEnvironmentSettings?: CodeEnvironmentUserSettings; + /** Server-derived conversation identity, activated only after worker capability negotiation. */ + conversationWorkspaceInstanceId?: string; /** Live, server-validated directory selection. Never derive session reuse from this field. */ codeWorkspace?: CodeWorkspaceSelection & { operations: CodeWorkspaceOperation[]; + workspaceInstanceId?: string; instructions?: CodeWorkspaceDescriptor['instructions']; environment?: CodeWorkspaceDescriptor['environment']; }; @@ -113,6 +116,7 @@ export function captureCodeExecutionApprovalBinding( : { environmentId: context.codeWorkspace.environmentId, workspaceId: context.codeWorkspace.workspaceId, + workspaceInstanceId: context.codeWorkspace.workspaceInstanceId ?? null, operations: [...new Set(context.codeWorkspace.operations)].sort(), ...(context.codeWorkspace.environment ? { definitionFingerprint: context.codeWorkspace.environment.fingerprint } @@ -347,6 +351,13 @@ export function resolveCodeExecutionContext(params: { conversationId: params.conversationId, }); const executionRouteKey = createCodeExecutionRouteKey('stateful', configuredEnvironment); + const conversationWorkspaceInstanceId = + configuredEnvironment?.type === 'attached' && params.conversationId + ? createHash('sha256') + .update('librechat-conversation-workspace-v1\0') + .update(JSON.stringify([params.userId, params.conversationId, configuredEnvironment.id])) + .digest('hex') + : undefined; return { baseUrl: getCodeExecutionBaseUrl('stateful', configuredEnvironment), codeSessionKey: `${Constants.EXECUTE_CODE}:${executionRouteKey}:${runtimeSessionHint}`, @@ -359,6 +370,7 @@ export function resolveCodeExecutionContext(params: { bridgeWorkerId: configuredEnvironment?.workerId ?? configuredEnvironment?.pairing?.workerId, codeEnvironmentConfigSchema: configuredEnvironment?.configSchema, codeEnvironmentSettings: configuredEnvironment?.settings, + ...(conversationWorkspaceInstanceId ? { conversationWorkspaceInstanceId } : {}), }; } diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index acbae9698dc..bf71ed678c6 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -4582,6 +4582,13 @@ describe('createToolExecuteHandler', () => { executionProfile: 'stateful', statefulSessions: true, environmentType: 'attached', + environmentId: 'personal-machine', + codeWorkspace: { + environmentId: 'personal-machine', + workspaceId: 'project-a', + workspaceInstanceId: 'a'.repeat(64), + operations: TEST_ATTACHED_WORKSPACE_OPERATIONS, + }, codeEnvironmentConfigSchema: { limits: { maxQueueWaitMs: 0 } }, bridgeWorkerId: 'user-worker', }, @@ -4612,6 +4619,7 @@ describe('createToolExecuteHandler', () => { content: 'export const ok = 1;', overwrite: false, workspace_id: 'project-a', + workspace_instance_id: 'a'.repeat(64), maxQueueWaitMs: 0, codeApiBaseUrl: 'https://code.example.com', executionProfile: 'stateful', @@ -5590,6 +5598,13 @@ describe('createToolExecuteHandler', () => { codeSessionKey: 'execute_code:stateful:attached', executionProfile: 'stateful', environmentType: 'attached', + environmentId: 'personal-machine', + codeWorkspace: { + environmentId: 'personal-machine', + workspaceId: 'project-a', + workspaceInstanceId: 'b'.repeat(64), + operations: TEST_ATTACHED_WORKSPACE_OPERATIONS, + }, codeEnvironmentConfigSchema: { limits: { maxQueueWaitMs: 0 } }, bridgeWorkerId: 'personal-worker-1', statefulSessions: true, @@ -5609,6 +5624,7 @@ describe('createToolExecuteHandler', () => { expect(readWorkspaceFile).toHaveBeenCalledWith({ file_path: 'src/app.ts', workspace_id: 'project-a', + workspace_instance_id: 'b'.repeat(64), maxQueueWaitMs: 0, start_line: 1, max_lines: 200, diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 2e5982bc2d8..61a4caf42f2 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -539,6 +539,7 @@ export interface ToolExecuteOptions { readWorkspaceFile?: (params: { file_path: string; workspace_id: string; + workspace_instance_id?: string; start_line: number; max_lines: number; codeApiBaseUrl: string; @@ -552,6 +553,7 @@ export interface ToolExecuteOptions { searchWorkspace?: (params: { query: string; workspace_id: string; + workspace_instance_id?: string; path?: string; max_results: number; codeApiBaseUrl: string; @@ -564,6 +566,7 @@ export interface ToolExecuteOptions { /** Lists relative file paths within an attached worker's logical workspace. */ listWorkspaceFiles?: (params: { workspace_id: string; + workspace_instance_id?: string; path?: string; after_path?: string; max_results: number; @@ -580,6 +583,7 @@ export interface ToolExecuteOptions { content: string; overwrite: boolean; workspace_id: string; + workspace_instance_id?: string; codeApiBaseUrl: string; executionProfile: CodeExecutionContext['executionProfile']; bridgeWorkerId?: string; @@ -592,6 +596,7 @@ export interface ToolExecuteOptions { file_path: string; edits: Array<{ oldText: string; newText: string }>; workspace_id: string; + workspace_instance_id?: string; codeApiBaseUrl: string; executionProfile: CodeExecutionContext['executionProfile']; bridgeWorkerId?: string; @@ -605,6 +610,7 @@ export interface ToolExecuteOptions { edits: Array<{ oldText: string; newText: string }>; expected_base_sha256?: string; workspace_id: string; + workspace_instance_id?: string; codeApiBaseUrl: string; executionProfile: CodeExecutionContext['executionProfile']; bridgeWorkerId?: string; @@ -2430,6 +2436,9 @@ async function handleWorkspaceFileRead( const result = await readWorkspaceFile({ file_path: filePath, workspace_id: workspaceId, + ...(codeExecutionContext.codeWorkspace?.workspaceInstanceId + ? { workspace_instance_id: codeExecutionContext.codeWorkspace.workspaceInstanceId } + : {}), start_line: startLine, max_lines: maxLines, codeApiBaseUrl: codeExecutionContext.baseUrl, @@ -2535,6 +2544,9 @@ async function handleWorkspaceSearchCall( const result = await options.searchWorkspace({ query: args.query, workspace_id: workspaceId, + ...(codeExecutionContext.codeWorkspace?.workspaceInstanceId + ? { workspace_instance_id: codeExecutionContext.codeWorkspace.workspaceInstanceId } + : {}), ...(typeof args.path === 'string' && args.path.length > 0 ? { path: args.path } : {}), max_results: Number(maxResults), codeApiBaseUrl: codeExecutionContext.baseUrl, @@ -2622,6 +2634,9 @@ async function handleWorkspaceListCall( try { const result = await options.listWorkspaceFiles({ workspace_id: workspaceId, + ...(codeExecutionContext.codeWorkspace?.workspaceInstanceId + ? { workspace_instance_id: codeExecutionContext.codeWorkspace.workspaceInstanceId } + : {}), ...(typeof args.path === 'string' && args.path.length > 0 ? { path: args.path } : {}), ...(typeof args.after_path === 'string' && args.after_path.length > 0 ? { after_path: args.after_path } @@ -3789,6 +3804,7 @@ function attachedWorkspaceMutationParams( signal: AbortSignal | undefined, ): { workspace_id: string; + workspace_instance_id?: string; codeApiBaseUrl: string; executionProfile: CodeExecutionContext['executionProfile']; bridgeWorkerId?: string; @@ -3798,6 +3814,9 @@ function attachedWorkspaceMutationParams( } { return { workspace_id: workspaceId, + ...(codeExecutionContext.codeWorkspace?.workspaceInstanceId + ? { workspace_instance_id: codeExecutionContext.codeWorkspace.workspaceInstanceId } + : {}), codeApiBaseUrl: codeExecutionContext.baseUrl, maxQueueWaitMs: resolveAttachedWorkspaceQueueWaitMs( codeExecutionContext.codeEnvironmentConfigSchema, diff --git a/packages/api/src/code/bridge.spec.ts b/packages/api/src/code/bridge.spec.ts index db1af81812d..54ebe61c761 100644 --- a/packages/api/src/code/bridge.spec.ts +++ b/packages/api/src/code/bridge.spec.ts @@ -92,7 +92,11 @@ describe('getCodeBridgeWorkerStatus', () => { protocolVersion: 1, operations: ['read_file', 'execute_command'], workspaces: [ - { id: 'project-a', name: 'Project A' }, + { + id: 'project-a', + name: 'Project A', + workspaceInstances: ['git_worktree'], + }, { id: 'docs', operations: ['read_file'] }, ], }, @@ -118,7 +122,11 @@ describe('getCodeBridgeWorkerStatus', () => { runtimes: ['bash'], operations: ['read_file', 'execute_command'], workspaces: [ - { id: 'project-a', name: 'Project A' }, + { + id: 'project-a', + name: 'Project A', + workspaceInstances: ['git_worktree'], + }, { id: 'docs', operations: ['read_file'] }, ], }); @@ -192,6 +200,21 @@ describe('getCodeBridgeWorkerStatus', () => { }, }, }, + { + online: true, + ready: true, + leaseExpiresInMs: 5_000, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'native-srt', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'project-a', workspaceInstances: ['container'] }], + }, + }, + }, { online: true, ready: true, diff --git a/packages/api/src/code/bridge.ts b/packages/api/src/code/bridge.ts index b44701804ad..216b317fe2d 100644 --- a/packages/api/src/code/bridge.ts +++ b/packages/api/src/code/bridge.ts @@ -192,6 +192,7 @@ function validWorkspaceCapabilities(value: unknown): value is { key !== 'id' && key !== 'name' && key !== 'operations' && + key !== 'workspaceInstances' && key !== 'environment' && key !== 'instructions', ) || @@ -203,6 +204,10 @@ function validWorkspaceCapabilities(value: unknown): value is { workspace.instructions.length > 1 || !workspace.instructions.every(isRepositoryInstructionDescriptor))) || (workspace.environment !== undefined && !isCodeWorkspaceEnvironment(workspace.environment)) || + (workspace.workspaceInstances !== undefined && + (!Array.isArray(workspace.workspaceInstances) || + workspace.workspaceInstances.length !== 1 || + workspace.workspaceInstances[0] !== 'git_worktree')) || (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 a473389ca23..d3b04781b28 100644 --- a/packages/api/src/code/capabilities.spec.ts +++ b/packages/api/src/code/capabilities.spec.ts @@ -423,6 +423,34 @@ describe('resolveCodeExecutionWorkspaceContext', () => { expect(resolved.codeWorkspace?.environment).toEqual(environment); }); + it('activates a server-derived instance only when the worker advertises worktree support', async () => { + const workspaceInstanceId = 'c'.repeat(64); + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + workspaceStatus([ + { id: 'worktree', workspaceInstances: ['git_worktree'] }, + { id: 'legacy' }, + ]), + ); + + const supported = await resolveCodeExecutionWorkspaceContext({ + context: { ...context, conversationWorkspaceInstanceId: workspaceInstanceId }, + requestedSelections: [{ environmentId: 'personal', workspaceId: 'worktree' }], + environments, + getAppConfig, + }); + const legacy = await resolveCodeExecutionWorkspaceContext({ + context: { ...context, conversationWorkspaceInstanceId: workspaceInstanceId }, + requestedSelections: [{ environmentId: 'personal', workspaceId: 'legacy' }], + environments, + getAppConfig, + }); + + expect(supported.codeWorkspace?.workspaceInstanceId).toBe(workspaceInstanceId); + expect(legacy.codeWorkspace).not.toHaveProperty('workspaceInstanceId'); + }); + 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 4f76a2797e4..525a203f5cc 100644 --- a/packages/api/src/code/capabilities.ts +++ b/packages/api/src/code/capabilities.ts @@ -178,6 +178,10 @@ export async function resolveCodeExecutionWorkspaceContext({ codeWorkspace: { ...selection, operations: [...(workspace.operations ?? status.operations)], + ...(context.conversationWorkspaceInstanceId && + workspace.workspaceInstances?.includes('git_worktree') + ? { workspaceInstanceId: context.conversationWorkspaceInstanceId } + : {}), ...(workspace.instructions ? { instructions: workspace.instructions } : {}), ...(workspace.environment ? { environment: workspace.environment } : {}), }, diff --git a/packages/api/src/code/command.spec.ts b/packages/api/src/code/command.spec.ts index 769878928b3..00e43558cd0 100644 --- a/packages/api/src/code/command.spec.ts +++ b/packages/api/src/code/command.spec.ts @@ -108,6 +108,26 @@ function commandResponse(overrides: Record = {}): Response { } describe('createAttachedWorkspaceBashTool', () => { + test('dispatches commands to the resolved conversation workspace instance', async () => { + const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse()); + const workspaceInstanceId = 'e'.repeat(64); + const bashTool = createAttachedWorkspaceBashTool({ + baseUrl: 'https://code.example.com/v1/', + authHeaders: () => ({}), + workspaceId: 'project-a', + workspaceInstanceId, + fetchImpl, + }); + + await bashTool.invoke({ command: 'pwd' }); + + const [, options] = (fetchImpl as jest.Mock).mock.calls[0]; + expect(JSON.parse(options.body)).toMatchObject({ + workspaceId: 'project-a', + workspaceInstanceId, + }); + }); + test('dispatches only advertised named actions with the resolved definition fingerprint', async () => { const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse()); const bashTool = createAttachedWorkspaceBashTool({ diff --git a/packages/api/src/code/command.ts b/packages/api/src/code/command.ts index 6b1f90d6618..f5d82a6db94 100644 --- a/packages/api/src/code/command.ts +++ b/packages/api/src/code/command.ts @@ -198,23 +198,24 @@ export function createContextProgrammaticBashTool( identity?: AgentGitIdentity | null, ): DynamicStructuredTool { const attached = context?.environmentType === 'attached'; - return createGitIdentityProgrammaticBashTool( - { - authHeaders, - baseUrl: context?.baseUrl, - executionProfile: context?.executionProfile, - runtimeSessionHint: context?.runtimeSessionHint, - ...(attached - ? { - workspaceId: context.codeWorkspace?.workspaceId, - runTimeoutMs: resolveAttachedWorkspaceCommandTimeoutMax( - context.codeEnvironmentConfigSchema, - ), - } - : {}), - }, - attached ? identity : undefined, - ); + const options: Parameters[0] & { + workspaceInstanceId?: string; + } = { + authHeaders, + baseUrl: context?.baseUrl, + executionProfile: context?.executionProfile, + runtimeSessionHint: context?.runtimeSessionHint, + ...(attached + ? { + workspaceId: context.codeWorkspace?.workspaceId, + workspaceInstanceId: context.codeWorkspace?.workspaceInstanceId, + runTimeoutMs: resolveAttachedWorkspaceCommandTimeoutMax( + context.codeEnvironmentConfigSchema, + ), + } + : {}), + }; + return createGitIdentityProgrammaticBashTool(options, attached ? identity : undefined); } /** Apply authorship before the SDK prepares the script and its replay requests. */ @@ -248,6 +249,7 @@ export function createAttachedWorkspaceBashTool({ baseUrl, authHeaders, workspaceId, + workspaceInstanceId, environment, gitIdentity, maxTimeoutMs = WORKSPACE_COMMAND_DEFAULT_TIMEOUT_MS, @@ -257,6 +259,7 @@ export function createAttachedWorkspaceBashTool({ baseUrl: string; authHeaders: () => Promise> | Record; workspaceId: string; + workspaceInstanceId?: string; environment?: CodeWorkspaceDescriptor['environment']; gitIdentity?: AgentGitIdentity | null; /** Deployment ceiling already intersected with the protocol hard cap. */ @@ -326,6 +329,7 @@ export function createAttachedWorkspaceBashTool({ protocolVersion: 1, operation: 'execute_command', workspaceId, + ...(workspaceInstanceId ? { workspaceInstanceId } : {}), command, ...(action && environment ? { environmentAction: { name: action, fingerprint: environment.fingerprint } } diff --git a/packages/api/src/code/instructions.spec.ts b/packages/api/src/code/instructions.spec.ts index 9a26bf9391b..f5b92a0e5e9 100644 --- a/packages/api/src/code/instructions.spec.ts +++ b/packages/api/src/code/instructions.spec.ts @@ -103,6 +103,38 @@ describe('repository instruction loading', () => { expect(await load({ ...input, mode: 'defer' })).toContain('unless they conflict'); }); + it('reads and caches instructions within the resolved conversation worktree', async () => { + const load = createRepositoryInstructionLoader(); + let requestBody: string | undefined; + const fetchImpl = jest.fn( + async (_input: Parameters[0], init?: Parameters[1]) => { + requestBody = init?.body as string | undefined; + return response(); + }, + ); + const workspaceInstanceId = 'f'.repeat(64); + const scopedContext = { + ...context, + codeWorkspace: { ...context.codeWorkspace!, workspaceInstanceId }, + }; + const input = { + enabled: true, + context: scopedContext, + principalId: 'alice', + fetchImpl, + authHeaders: async () => ({}), + assertContent: jest.fn(), + }; + + await load(input); + await load(input); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(JSON.parse(requestBody!)).toMatchObject({ + workspaceInstanceId, + }); + }); + it('omits changed, missing and unauthorized instruction snapshots', async () => { const load = createRepositoryInstructionLoader(); const input = { diff --git a/packages/api/src/code/instructions.ts b/packages/api/src/code/instructions.ts index e34027eaf62..74db097616a 100644 --- a/packages/api/src/code/instructions.ts +++ b/packages/api/src/code/instructions.ts @@ -82,6 +82,7 @@ export function createRepositoryInstructionLoader() { context.baseUrl, context.bridgeWorkerId, workspace.workspaceId, + workspace.workspaceInstanceId ?? null, descriptor.path, descriptor.sha256, ]); @@ -103,6 +104,9 @@ export function createRepositoryInstructionLoader() { protocolVersion: 1, operation: 'read_file', workspaceId: workspace.workspaceId, + ...(workspace.workspaceInstanceId + ? { workspaceInstanceId: workspace.workspaceInstanceId } + : {}), path: descriptor.path, instructionSha256: descriptor.sha256, }, diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index 8aa3b212887..84b02735a73 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -3,6 +3,57 @@ import type { CodeBridgeFetch } from './bridge'; import { executeWorkspaceTool, WorkspaceToolHttpError } from './workspace'; describe('workspace admission feedback', () => { + test('forwards a valid conversation workspace instance unchanged', async () => { + const workspaceInstanceId = 'd'.repeat(64); + const fetchImpl = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: 'notes.txt', + created: true, + bytesWritten: 5, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await executeWorkspaceTool({ + baseURL: 'https://code.example/v1', + authHeaders: {}, + fetchImpl, + request: { + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + workspaceInstanceId, + path: 'notes.txt', + content: 'ready', + }, + }); + + expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toMatchObject({ workspaceInstanceId }); + }); + + test('rejects malformed workspace instance identifiers before transport', async () => { + const fetchImpl = jest.fn(); + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example/v1', + authHeaders: {}, + fetchImpl, + request: { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + workspaceInstanceId: 'conversation-1', + }, + }), + ).rejects.toBeInstanceOf(WorkspaceToolHttpError); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + test('identifies definite pre-execution expiry without retrying the operation', async () => { const fetchImpl = jest.fn().mockResolvedValue( new Response(JSON.stringify({ code: 'WORKSPACE_QUEUE_TIMEOUT' }), { diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index af5f55336a2..3de4d8ab5ff 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -99,6 +99,7 @@ export interface WorkspaceReadRequest { protocolVersion: 1; operation: 'read_file'; workspaceId: string; + workspaceInstanceId?: string; path: string; startLine?: number; maxLines?: number; @@ -109,6 +110,7 @@ export interface WorkspaceSearchRequest { protocolVersion: 1; operation: 'search_text'; workspaceId: string; + workspaceInstanceId?: string; query: string; path?: string; maxResults?: number; @@ -118,6 +120,7 @@ export interface WorkspaceListRequest { protocolVersion: 1; operation: 'list_files'; workspaceId: string; + workspaceInstanceId?: string; path?: string; maxResults?: number; afterPath?: string; @@ -127,6 +130,7 @@ export interface WorkspaceExecuteCommandRequest { protocolVersion: 1; operation: 'execute_command'; workspaceId: string; + workspaceInstanceId?: string; command: string; cwd?: string; timeoutMs?: number; @@ -138,6 +142,7 @@ export interface WorkspaceWriteRequest { protocolVersion: 1; operation: 'write_file'; workspaceId: string; + workspaceInstanceId?: string; path: string; content: string; overwrite?: boolean; @@ -152,6 +157,7 @@ export interface WorkspaceEditRequest { protocolVersion: 1; operation: 'edit_file'; workspaceId: string; + workspaceInstanceId?: string; path: string; edits: WorkspaceTextEdit[]; expectedBaseSha256?: string; @@ -161,6 +167,7 @@ export interface WorkspacePreviewEditRequest { protocolVersion: 1; operation: 'preview_edit'; workspaceId: string; + workspaceInstanceId?: string; path: string; edits: WorkspaceTextEdit[]; } @@ -486,7 +493,9 @@ async function readBoundedJson(response: Response, signal?: AbortSignal): Promis function isValidRequest(request: WorkspaceToolRequest): boolean { if ( request.protocolVersion !== 1 || - !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(request.workspaceId) + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(request.workspaceId) || + (request.workspaceInstanceId !== undefined && + !/^[a-f0-9]{64}$/.test(request.workspaceInstanceId)) ) { return false; } diff --git a/packages/data-provider/src/code/workspace.ts b/packages/data-provider/src/code/workspace.ts index f1d2b2d1d90..b431232d96d 100644 --- a/packages/data-provider/src/code/workspace.ts +++ b/packages/data-provider/src/code/workspace.ts @@ -14,6 +14,7 @@ export const CODE_WORKSPACE_OPERATIONS = [ 'edit_file', 'execute_command', ] as const; +export const CODE_WORKSPACE_INSTANCE_TYPES = ['git_worktree'] as const; export const CODE_WORKSPACE_SELECTION_ERROR_REASONS = [ 'required', 'invalid', @@ -25,6 +26,7 @@ export const CODE_WORKSPACE_SELECTION_ERROR_REASONS = [ export const CODE_ENVIRONMENT_MODES = ['attached', 'without_attached'] as const; export type CodeWorkspaceOperation = (typeof CODE_WORKSPACE_OPERATIONS)[number]; +export type CodeWorkspaceInstanceType = (typeof CODE_WORKSPACE_INSTANCE_TYPES)[number]; export type CodeWorkspaceSelectionErrorReason = (typeof CODE_WORKSPACE_SELECTION_ERROR_REASONS)[number]; export type CodeEnvironmentMode = (typeof CODE_ENVIRONMENT_MODES)[number]; @@ -36,6 +38,8 @@ export interface CodeWorkspaceDescriptor { instructions?: RepositoryInstructionDescriptor[]; /** Omitted when every worker-level operation applies to this workspace. */ operations?: CodeWorkspaceOperation[]; + /** Optional worker-managed isolation modes available beneath this root. */ + workspaceInstances?: CodeWorkspaceInstanceType[]; environment?: { fingerprint: string; repo?: string; From be1e5997ae68620b39b6443cfa04af563f5a090a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 19 Sep 2026 22:23:41 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20chore:=20bump=20agents?= =?UTF-8?q?=20to=203.8.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 2 +- package-lock.json | 10 +++++----- packages/api/package.json | 2 +- packages/api/src/code/command.spec.ts | 19 +++++++++++++++---- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/api/package.json b/api/package.json index f226bf0f558..6c925bfad76 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.8.7", + "@librechat/agents": "^3.8.8", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index 5513c411c60..638785d4ded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.8.7", + "@librechat/agents": "^3.8.8", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -10622,9 +10622,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.8.7", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.8.7.tgz", - "integrity": "sha512-yIO/SnWakpKXLzaRCNJfaai0emp22cD7mO8ffzu8gDyIVTzK0jaoG1nrr0+PoVkIeK/piKb+QJEyobPecGX6fg==", + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.8.8.tgz", + "integrity": "sha512-sfvYhCRUpbK5kV/NUe1MG5xu7fgO6t81vcmejy8mDCTpM5r6IbtGeW9PlwHVVZXwnFWn5mY4K02VLCA76IHLyg==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.115.0", @@ -42449,7 +42449,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.8.7", + "@librechat/agents": "^3.8.8", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.30.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/package.json b/packages/api/package.json index 9f155ddc419..dd7d333da14 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -120,7 +120,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.8.7", + "@librechat/agents": "^3.8.8", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.30.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/src/code/command.spec.ts b/packages/api/src/code/command.spec.ts index e1ce314990e..0cd2dd12f8f 100644 --- a/packages/api/src/code/command.spec.ts +++ b/packages/api/src/code/command.spec.ts @@ -76,15 +76,20 @@ describe('programmatic Bash Git identity', () => { } }); - test('pins a selected project on the real SDK no-tools route', async () => { - const received: { url?: string; workspace?: string | string[]; code: string }[] = []; + test('pins the selected project and conversation instance on the real SDK no-tools route', async () => { + const received: { + url?: string; + workspace?: string | string[]; + code: string; + workspace_instance_id?: string; + }[] = []; const server = createServer(async (req, res) => { let body = ''; for await (const chunk of req) body += chunk; received.push({ url: req.url, workspace: req.headers['x-librechat-code-workspace-id'], - code: JSON.parse(body).code, + ...JSON.parse(body), }); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ status: 'completed', stdout: 'done', stderr: '', files: [] })); @@ -92,11 +97,13 @@ describe('programmatic Bash Git identity', () => { server.listen(0, '127.0.0.1'); await once(server, 'listening'); const { port } = server.address() as AddressInfo; + const workspaceInstanceId = 'a'.repeat(64); try { const bashTool = createGitIdentityProgrammaticBashTool( { baseUrl: `http://127.0.0.1:${port}/v1`, workspaceId: 'project-a', + workspaceInstanceId, authHeaders: () => ({}), }, { name: 'Lia', email: 'lia@example.com' }, @@ -108,7 +115,11 @@ describe('programmatic Bash Git identity', () => { invocationConfig, ); expect(received).toHaveLength(1); - expect(received[0]).toMatchObject({ url: '/v1/exec/programmatic', workspace: 'project-a' }); + expect(received[0]).toMatchObject({ + url: '/v1/exec/programmatic', + workspace: 'project-a', + workspace_instance_id: workspaceInstanceId, + }); expect(received[0].code).toContain("GIT_AUTHOR_NAME='Lia'"); expect(bashTool.description).toContain('selected persistent workspace'); } finally {