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
11 changes: 6 additions & 5 deletions docs/remote-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,15 @@ The endpoint uses the same authenticated principal-bound worker selection,
tenant fence, lease deadline, cancellation, and settlement lifecycle as remote
sandbox execution. Requests must name a workspace and operation advertised by
that worker. Results are validated against the originating request before they
leave Code API, and are bounded to 1 MiB/500 lines for reads or 200 matches for
searches. Absolute paths, traversal, backslashes, symlink escapes, unexpected
fields, and host roots are rejected.
leave Code API, and are bounded to 1 MiB/500 lines for reads, 200 matches for
searches, or 500 relative paths for file listings. Absolute paths, traversal,
backslashes, symlink escapes, unexpected fields, and host roots are rejected.

The workspace root can be an existing project, a Git repository, or an empty
directory; Git is not required. This boundary keeps that directory local to the
operator's machine, but the selected file contents, search matches, and later
tool results necessarily cross the outbound bridge to Code API and the model.
operator's machine, but selected file contents, search matches, relative file
listings, and later tool results necessarily cross the outbound bridge to Code
API and the model.
Treat them as explicit tool outputs, apply the same retention and audit policy
as chat content, and do not register a directory containing secrets. The
default operations are read-only; future mutation and shell operations must be
Expand Down
25 changes: 14 additions & 11 deletions packages/code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ coding-agent access to workspace directories on the worker machine. A workspace
may be an existing project, a Git repository, or a newly created empty
directory; Git is optional.
`LocalWorkspaceTools` registers opaque workspace IDs with optional display
names and exposes bounded `read_file` and literal `search_text` operations.
names and exposes bounded `read_file`, literal `search_text`, and deterministic
`list_files` operations.
Only IDs, names, protocol version, and supported operations appear in worker
capabilities; absolute host paths remain local to the worker process.

Expand All @@ -204,10 +205,11 @@ and files larger than 1 MiB. The opened file is checked against its canonical
in-workspace inode before it is read. Text search uses `rg` only to enumerate a
bounded set of ignored-aware candidates with configuration and symlink following
disabled. It then opens and verifies each candidate through the same confined
1 MiB read boundary before matching locally. Search limits returned line length
and stops after a bounded global result count. The worker process still belongs
inside the trusted BYOM boundary and should receive filesystem access only to
roots the operator intentionally registers.
1 MiB read boundary before matching locally. File listing invokes `rg` without
a shell, with configuration and symlink following disabled. Both operations
stop after bounded global result counts. The worker process still belongs inside
the trusted BYOM boundary and should receive filesystem access only to roots the
operator intentionally registers.

Register one directory already present on the worker machine with the
worker-directory option:
Expand All @@ -220,17 +222,18 @@ The default public workspace ID is `primary` and the default display name is
the directory basename. Operators can use `--workspace-id` and
`--workspace-name`, or `LIBRECHAT_CODE_WORKER_DIR`,
`LIBRECHAT_CODE_WORKSPACE_ID`, and `LIBRECHAT_CODE_WORKSPACE_NAME`, to set them
explicitly. `rg` must be installed on the worker for `search_text`.
explicitly. `rg` must be installed on the worker for `search_text` and
`list_files`.

The worker advertises these capabilities only when a directory is configured
and executes matching assignments under the bridge's existing lease,
deadline, cancellation, credential-refresh, and settlement fencing. The
workspace itself remains on the worker. As with Cursor's self-hosted agents,
text deliberately selected by `read_file` or `search_text` crosses the outbound
bridge so the remote agent/model can reason over it. Host paths are never part
of that payload. The Code API workspace-tool endpoint is delivered as a
dependent layer; deployments without it continue to use sandbox assignments
unchanged.
text and relative paths deliberately selected by `read_file`, `search_text`, or
`list_files` cross the outbound bridge so the remote agent/model can reason over
them. Host paths are never part of that payload. The Code API workspace-tool
endpoint is delivered as a dependent layer; deployments without it continue to
use sandbox assignments unchanged.

After discarding or resetting that session's local runner, acknowledge recovery
with `librechat-code reset-workspace <runtime-session-id>`. The command uses the
Expand Down
59 changes: 59 additions & 0 deletions packages/code/src/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
bridgeWorkerPath,
isValidBridgeWorkerCapabilities,
isValidBridgeWorkerId,
isWorkspaceToolRequest,
isWorkspaceToolResult,
} from './protocol.js';

test('bridgeWorkerPath encodes worker-controlled path segments', () => {
Expand Down Expand Up @@ -89,3 +91,60 @@ test('bridge worker capabilities accept only bounded public workspace descriptor
false,
);
});

test('workspace file listing accepts only bounded portable requests and results', () => {
const request = {
protocolVersion: 1 as const,
operation: 'list_files' as const,
workspaceId: 'primary',
path: 'src',
maxResults: 20,
};
assert.equal(isWorkspaceToolRequest(request), true);
assert.equal(
isWorkspaceToolRequest({ ...request, path: '../outside' }),
false,
);
assert.equal(isWorkspaceToolRequest({ ...request, maxResults: 501 }), false);

const result = {
protocolVersion: 1 as const,
operation: 'list_files' as const,
workspaceId: 'primary',
paths: ['src/app.ts', 'src/worker.ts'],
truncated: false,
};
assert.equal(isWorkspaceToolResult(request, result), true);
assert.equal(
isWorkspaceToolResult(request, {
...result,
paths: ['/Users/operator/private'],
}),
false,
);
assert.equal(
isWorkspaceToolResult(request, { ...result, paths: ['outside.txt'] }),
false,
);
assert.equal(
isWorkspaceToolResult(request, {
...result,
paths: ['src/app.ts', 'src/app.ts'],
}),
false,
);
assert.equal(
isWorkspaceToolResult(request, {
...result,
paths: ['src/app.ts', 'src/./app.ts'],
}),
false,
);
assert.equal(
isWorkspaceToolResult(request, {
...result,
root: '/private/workspace',
}),
false,
);
});
92 changes: 86 additions & 6 deletions packages/code/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ export const BRIDGE_WORKSPACE_READ_MAX_BYTES = 1024 * 1024;
export const BRIDGE_WORKSPACE_READ_MAX_LINES = 500;
export const BRIDGE_WORKSPACE_SEARCH_MAX_RESULTS = 200;
export const BRIDGE_WORKSPACE_SEARCH_TEXT_MAX_LENGTH = 2000;
export const BRIDGE_WORKSPACE_LIST_MAX_RESULTS = 500;

export type BridgeProtocolVersion = typeof BRIDGE_PROTOCOL_VERSION;

export type BridgeWorkspaceToolOperation = 'read_file' | 'search_text';
export type BridgeWorkspaceToolOperation =
| 'read_file'
| 'search_text'
| 'list_files';

export interface BridgeWorkspaceDescriptor {
id: string;
Expand Down Expand Up @@ -72,12 +76,30 @@ export interface WorkspaceSearchTextResult {
truncated: boolean;
}

export interface WorkspaceListFilesRequest {
protocolVersion: BridgeProtocolVersion;
operation: 'list_files';
workspaceId: string;
path?: string;
maxResults?: number;
}

export interface WorkspaceListFilesResult {
protocolVersion: BridgeProtocolVersion;
operation: 'list_files';
workspaceId: string;
paths: string[];
truncated: boolean;
}

export type WorkspaceToolRequest =
| WorkspaceReadFileRequest
| WorkspaceSearchTextRequest;
| WorkspaceSearchTextRequest
| WorkspaceListFilesRequest;
export type WorkspaceToolResult =
| WorkspaceReadFileResult
| WorkspaceSearchTextResult;
| WorkspaceSearchTextResult
| WorkspaceListFilesResult;

const WORKSPACE_READ_REQUEST_KEYS = new Set([
'protocolVersion',
Expand All @@ -95,6 +117,13 @@ const WORKSPACE_SEARCH_REQUEST_KEYS = new Set([
'path',
'maxResults',
]);
const WORKSPACE_LIST_REQUEST_KEYS = new Set([
'protocolVersion',
'operation',
'workspaceId',
'path',
'maxResults',
]);
const WORKSPACE_READ_RESULT_KEYS = new Set([
'protocolVersion',
'operation',
Expand All @@ -113,6 +142,13 @@ const WORKSPACE_SEARCH_RESULT_KEYS = new Set([
'matches',
'truncated',
]);
const WORKSPACE_LIST_RESULT_KEYS = new Set([
'protocolVersion',
'operation',
'workspaceId',
'paths',
'truncated',
]);
const WORKSPACE_SEARCH_MATCH_KEYS = new Set([
'path',
'line',
Expand Down Expand Up @@ -144,6 +180,8 @@ export interface BridgeWorkerRegistrationResponse {
registrationGeneration?: number;
registeredAt: string;
leaseTtlMs: number;
/** Operations this Code API can dispatch after the worker advertises them. */
supportedWorkspaceToolOperations?: BridgeWorkspaceToolOperation[];
}

export interface BridgePairingRedemption {
Expand Down Expand Up @@ -212,6 +250,8 @@ export type WorkspaceToolErrorCode =
| 'READ_LIMIT_EXCEEDED'
| 'REGISTRATION_INVALID'
| 'EXECUTION_ABORTED'
| 'LIST_TIMEOUT'
| 'LIST_UNAVAILABLE'
| 'SEARCH_TIMEOUT'
| 'SEARCH_UNAVAILABLE';

Expand All @@ -221,6 +261,8 @@ const WORKSPACE_TOOL_ERROR_CODES = new Set<WorkspaceToolErrorCode>([
'READ_LIMIT_EXCEEDED',
'REGISTRATION_INVALID',
'EXECUTION_ABORTED',
'LIST_TIMEOUT',
'LIST_UNAVAILABLE',
'SEARCH_TIMEOUT',
'SEARCH_UNAVAILABLE',
]);
Expand Down Expand Up @@ -266,7 +308,7 @@ export function isValidBridgeWorkerId(workerId: string): boolean {
return BRIDGE_WORKER_ID_PATTERN.test(workerId);
}

function isSafePortableRelativePath(value: unknown): value is string {
export function isSafePortableRelativePath(value: unknown): value is string {
if (
typeof value !== 'string' ||
value.length === 0 ||
Expand Down Expand Up @@ -354,6 +396,17 @@ export function isWorkspaceToolRequest(
Number(request.maxResults) <= BRIDGE_WORKSPACE_SEARCH_MAX_RESULTS))
);
}
if (request.operation === 'list_files') {
return (
hasOnlyKeys(request, WORKSPACE_LIST_REQUEST_KEYS) &&
(request.path === undefined ||
isSafePortableRelativePath(request.path)) &&
(request.maxResults === undefined ||
(Number.isSafeInteger(request.maxResults) &&
Number(request.maxResults) >= 1 &&
Number(request.maxResults) <= BRIDGE_WORKSPACE_LIST_MAX_RESULTS))
);
}
return false;
}

Expand Down Expand Up @@ -405,6 +458,30 @@ export function isWorkspaceToolResult(
);
}

if (request.operation === 'list_files') {
const maxResults = request.maxResults ?? 100;
if (
!hasOnlyKeys(result, WORKSPACE_LIST_RESULT_KEYS) ||
!Array.isArray(result.paths) ||
result.paths.length > maxResults
) {
return false;
}
const normalizedPaths = new Set<string>();
for (const path of result.paths) {
if (
!isSafePortableRelativePath(path) ||
!isWithinRequestedPath(path, request.path)
) {
return false;
}
const normalizedPath = normalizePortableRelativePath(path);
if (normalizedPaths.has(normalizedPath)) return false;
normalizedPaths.add(normalizedPath);
}
return true;
}

if (!Array.isArray(result.matches)) return false;
const maxResults = request.maxResults ?? 50;
return (
Expand Down Expand Up @@ -438,9 +515,12 @@ export function isValidBridgeWorkspaceToolCapabilities(
capabilities.protocolVersion !== BRIDGE_PROTOCOL_VERSION ||
!Array.isArray(capabilities.operations) ||
capabilities.operations.length < 1 ||
capabilities.operations.length > 2 ||
capabilities.operations.length > 3 ||
!capabilities.operations.every(
(operation) => operation === 'read_file' || operation === 'search_text',
(operation) =>
operation === 'read_file' ||
operation === 'search_text' ||
operation === 'list_files',
) ||
new Set(capabilities.operations).size !== capabilities.operations.length ||
!Array.isArray(capabilities.workspaces) ||
Expand Down
Loading