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
2 changes: 2 additions & 0 deletions api/server/services/Endpoints/agents/skillDeps.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { batchUploadCodeEnvFiles } = require('~/server/services/Files/Code/crud')
const {
getSessionInfo,
checkIfActive,
readWorkspaceFile,
readSandboxFile,
readSandboxImage,
writeSandboxFile,
Expand Down Expand Up @@ -380,6 +381,7 @@ const skillToolDeps = {
updateSkillFileCodeEnvIds: deploymentSkillMethods.updateSkillFileCodeEnvIds,
getSkillFileByPath: deploymentSkillMethods.getSkillFileByPath,
updateSkillFileContent: deploymentSkillMethods.updateSkillFileContent,
readWorkspaceFile,
/**
* `read_file` falls back to a sandbox `cat` for `/mnt/data/...` paths
* and for `{firstSegment}/...` paths whose first segment isn't a known
Expand Down
8 changes: 8 additions & 0 deletions api/server/services/Endpoints/agents/skillDeps.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const mockGetFileStrategy = jest.fn();
const mockGetStorageMetadata = jest.fn();
const mockResolveRequestTenantId = jest.fn();
const mockCreateDeploymentSkillMethods = jest.fn((methods) => methods);
const mockReadWorkspaceFile = jest.fn();

jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args),
Expand All @@ -18,6 +19,7 @@ jest.mock('~/server/services/Files/Code/process', () => ({
getSessionInfo: jest.fn(),
checkIfActive: jest.fn(),
readSandboxFile: jest.fn(),
readWorkspaceFile: (...args) => mockReadWorkspaceFile(...args),
writeSandboxFile: jest.fn(),
}));

Expand Down Expand Up @@ -79,6 +81,12 @@ describe('skillDeps saveSkillFileContent', () => {
mockDb.getSkillFileByPath.mockResolvedValue(null);
});

it('exposes the stable attached-workspace reader to agent handlers', () => {
expect(getSkillToolDeps().readWorkspaceFile).toBeDefined();
getSkillToolDeps().readWorkspaceFile({ file_path: 'src/app.ts' });
expect(mockReadWorkspaceFile).toHaveBeenCalledWith({ file_path: 'src/app.ts' });
});

it('cleans up the uploaded object when metadata upsert returns no row', async () => {
mockDb.upsertSkillFile.mockResolvedValue(null);

Expand Down
48 changes: 48 additions & 0 deletions api/server/services/Files/Code/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const {
getCodeExecutionBaseUrl,
buildCodeEnvDownloadQuery,
codeExecutionHeaders,
executeWorkspaceTool,
claimCodeDestination,
createCodeDestinationSet,
CODE_OUTPUT_PREFLIGHT_MAX_BYTES,
Expand Down Expand Up @@ -1650,6 +1651,52 @@ async function readSandboxFile({
}
}

/**
* Reads a bounded range from the workspace directory registered by an attached worker.
* The authenticated worker route is derived from the selected environment and
* the host path remains private to the worker.
*
* @param {Object} params
* @param {string} params.file_path
* @param {string} params.workspace_id
* @param {number} params.start_line
* @param {number} params.max_lines
* @param {string} params.codeApiBaseUrl
* @param {'default' | 'stateful'} params.executionProfile
* @param {string} [params.bridgeWorkerId]
* @param {ServerRequest} [params.req]
* @param {AbortSignal} [params.signal]
*/
async function readWorkspaceFile({
file_path,
workspace_id,
start_line,
max_lines,
codeApiBaseUrl,
executionProfile,
bridgeWorkerId,
req,
signal,
}) {
const authHeaders = await getCodeApiAuthHeaders(req, bridgeWorkerId);
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
authHeaders: {
...authHeaders,
...codeExecutionHeaders({ executionProfile, bridgeWorkerId }),
},
request: {
protocolVersion: 1,
operation: 'read_file',
workspaceId: workspace_id,
path: file_path,
startLine: start_line,
maxLines: max_lines,
},
...(signal ? { signal } : {}),
});
}

/**
* Reads a small code artifact as base64 so `read_file` can surface it to
* vision-capable models. Reuses bytes fetched by the current request's
Expand Down Expand Up @@ -1916,6 +1963,7 @@ module.exports = {
getSessionInfo,
processCodeOutput,
prepareCodeOutputForInspection,
readWorkspaceFile,
readSandboxFile,
readSandboxImage,
writeSandboxFile,
Expand Down
54 changes: 54 additions & 0 deletions api/server/services/Files/Code/process.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const mockExtractCodeArtifactInspectionText = jest.fn(async () => ({
complete: false,
}));
const mockExtractCodeArtifactText = jest.fn(async () => null);
const mockExecuteWorkspaceTool = jest.fn();
const mockGetExtractedTextFormat = jest.fn((_name, _mime, text) => (text == null ? null : 'text'));
/* `hasOfficeHtmlPath` gates the persist-then-render split: when true, processCodeOutput
* returns `{ file, finalize }` with the file persisted at `status: 'pending'`
Expand All @@ -70,6 +71,7 @@ jest.mock('@librechat/api', () => {
sanitizeArtifactPath: jest.fn((name) => name),
flattenArtifactPath: jest.fn((name) => name.replace(/\//g, '__')),
createAxiosInstance: jest.fn(() => mockAxios),
executeWorkspaceTool: (...args) => mockExecuteWorkspaceTool(...args),
getCodeApiAuthHeaders: jest.fn(async () => ({})),
/* Windowing, sizing and rate-limit policy are real code in
* `packages/api` with their own tests (`files/code/image.spec.ts`,
Expand Down Expand Up @@ -240,6 +242,7 @@ const {
prepareCodeOutputForInspection,
getSessionInfo,
readSandboxFile,
readWorkspaceFile,
readSandboxImage,
writeSandboxFile,
primeFiles,
Expand Down Expand Up @@ -1832,6 +1835,57 @@ describe('Code Process', () => {
});
});

describe('readWorkspaceFile', () => {
it('forwards authenticated reads to the selected attached worker', async () => {
const controller = new AbortController();
const result = {
protocolVersion: 1,
operation: 'read_file',
workspaceId: 'primary',
path: 'src/app.ts',
content: 'const ready = true;',
startLine: 1,
endLine: 1,
truncated: false,
};
getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer workspace-token' });
mockExecuteWorkspaceTool.mockResolvedValueOnce(result);

await expect(
readWorkspaceFile({
file_path: 'src/app.ts',
workspace_id: 'primary',
start_line: 1,
max_lines: 200,
codeApiBaseUrl: 'https://attached-code.example.com/v1',
executionProfile: 'stateful',
bridgeWorkerId: 'worker-user-1',
req: mockReq,
signal: controller.signal,
}),
).resolves.toBe(result);

expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(mockReq, 'worker-user-1');
expect(mockExecuteWorkspaceTool).toHaveBeenCalledWith({
baseURL: 'https://attached-code.example.com/v1',
authHeaders: {
Authorization: 'Bearer workspace-token',
'X-CodeAPI-Expected-Profile': 'stateful',
'X-LibreChat-Code-Worker-ID': 'worker-user-1',
},
request: {
protocolVersion: 1,
operation: 'read_file',
workspaceId: 'primary',
path: 'src/app.ts',
startLine: 1,
maxLines: 200,
},
signal: controller.signal,
});
});
});

describe('readSandboxFile', () => {
/**
* `readSandboxFile` shells `cat <file_path>` through the sandbox
Expand Down
Loading
Loading