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 packages/code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ and server negotiate `create` in `writeFileModes`.
`edits` array; every exact replacement is validated before the updated file is
installed as one atomic mutation. Code API dispatches the batch form only after
the worker and server negotiate `batch` in `editFileModes`.
Revision-fenced edits likewise require the negotiated
`expected_base_sha256` entry in `editFileFeatures`.
Only IDs, names, protocol version, supported operations, and negotiated write
modes appear in worker capabilities; absolute host paths remain local to the
worker process.
Expand Down
80 changes: 79 additions & 1 deletion packages/code/src/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
isWorkspaceToolRequest,
isWorkspaceToolResult,
} from './protocol.js';
import type { WorkspaceEditFileRequest } from './protocol.js';
import type {
WorkspaceEditFileRequest,
WorkspacePreviewEditRequest,
} from './protocol.js';

const validSingleEditRequest: WorkspaceEditFileRequest = {
protocolVersion: 1,
Expand Down Expand Up @@ -39,6 +42,26 @@ const invalidMixedEditRequest: WorkspaceEditFileRequest = {
void invalidEmptyEditRequest;
void invalidMixedEditRequest;

// @ts-expect-error A preview request must choose a complete single or batch form.
const invalidEmptyPreviewRequest: WorkspacePreviewEditRequest = {
protocolVersion: 1,
operation: 'preview_edit',
workspaceId: 'primary',
path: 'notes.txt',
};
// @ts-expect-error Single and batch preview forms are mutually exclusive.
const invalidMixedPreviewRequest: WorkspacePreviewEditRequest = {
protocolVersion: 1,
operation: 'preview_edit',
workspaceId: 'primary',
path: 'notes.txt',
oldText: 'before',
newText: 'after',
edits: [{ oldText: 'before', newText: 'after' }],
};
void invalidEmptyPreviewRequest;
void invalidMixedPreviewRequest;

test('bridgeWorkerPath encodes worker-controlled path segments', () => {
assert.equal(
bridgeWorkerPath('vm/example worker'),
Expand Down Expand Up @@ -112,17 +135,39 @@ test('bridge worker capabilities accept only bounded public workspace descriptor
}),
true,
);
assert.equal(
isValidBridgeWorkerCapabilities({
...valid,
workspaceTools: {
...valid.workspaceTools,
operations: ['read_file', 'preview_edit'],
editFileModes: ['single', 'batch'],
},
}),
true,
);
assert.equal(
isValidBridgeWorkerCapabilities({
...valid,
workspaceTools: {
...valid.workspaceTools,
operations: ['read_file', 'edit_file'],
editFileModes: ['single', 'batch'],
editFileFeatures: ['expected_base_sha256'],
},
}),
true,
);
assert.equal(
isValidBridgeWorkerCapabilities({
...valid,
workspaceTools: {
...valid.workspaceTools,
editFileFeatures: ['expected_base_sha256'],
},
}),
false,
);
assert.equal(
isValidBridgeWorkerCapabilities({
...valid,
Expand Down Expand Up @@ -328,6 +373,39 @@ test('workspace mutations accept bounded UTF-8 requests and exact result shapes'
}),
true,
);
const previewRequest = {
...batchEditRequest,
operation: 'preview_edit' as const,
};
assert.equal(isWorkspaceToolRequest(previewRequest), true);
assert.equal(
isWorkspaceToolResult(previewRequest, {
protocolVersion: 1,
operation: 'preview_edit',
workspaceId: 'primary',
path: 'notes.txt',
content: 'goodbye BYOM',
hasUtf8Bom: false,
baseSha256: 'a'.repeat(64),
replacements: 2,
bytesWritten: 12,
}),
true,
);
assert.equal(
isWorkspaceToolRequest({
...editRequest,
expectedBaseSha256: 'b'.repeat(64),
}),
true,
);
assert.equal(
isWorkspaceToolRequest({
...editRequest,
expectedBaseSha256: 'not-a-sha',
}),
false,
);
});

test('workspace commands require bounded sandbox inputs and outputs', () => {
Expand Down
114 changes: 112 additions & 2 deletions packages/code/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ export type BridgeWorkspaceToolOperation =
| 'search_text'
| 'list_files'
| 'write_file'
| 'preview_edit'
| 'edit_file'
| 'execute_command';

export type WorkspaceWriteFileMode = 'replace' | 'create';
export type WorkspaceEditFileMode = 'single' | 'batch';
export type WorkspaceEditFileFeature = 'expected_base_sha256';

export interface BridgeWorkspaceDescriptor {
id: string;
Expand All @@ -49,6 +51,8 @@ export interface BridgeWorkspaceToolCapabilities {
writeFileModes?: WorkspaceWriteFileMode[];
/** Omitted by legacy workers, which only accept single exact replacements. */
editFileModes?: WorkspaceEditFileMode[];
/** Omitted by workers that cannot fence edits against a preview revision. */
editFileFeatures?: WorkspaceEditFileFeature[];
}

export interface WorkspaceReadFileRequest {
Expand Down Expand Up @@ -136,6 +140,8 @@ interface WorkspaceEditFileRequestBase {
operation: 'edit_file';
workspaceId: string;
path: string;
/** Refuses the mutation unless current file bytes match this preview revision. */
expectedBaseSha256?: string;
}

export interface WorkspaceSingleEditFileRequest
Expand Down Expand Up @@ -173,6 +179,43 @@ export interface WorkspaceEditFileResult {
bytesWritten: number;
}

interface WorkspacePreviewEditRequestBase {
protocolVersion: BridgeProtocolVersion;
operation: 'preview_edit';
workspaceId: string;
path: string;
}

export interface WorkspaceSinglePreviewEditRequest
extends WorkspacePreviewEditRequestBase {
oldText: string;
newText: string;
edits?: never;
}

export interface WorkspaceBatchPreviewEditRequest
extends WorkspacePreviewEditRequestBase {
edits: WorkspaceTextEdit[];
oldText?: never;
newText?: never;
}

export type WorkspacePreviewEditRequest =
| WorkspaceSinglePreviewEditRequest
| WorkspaceBatchPreviewEditRequest;

export interface WorkspacePreviewEditResult {
protocolVersion: BridgeProtocolVersion;
operation: 'preview_edit';
workspaceId: string;
path: string;
content: string;
hasUtf8Bom: boolean;
baseSha256: string;
replacements: number;
bytesWritten: number;
}

export interface WorkspaceExecuteCommandRequest {
protocolVersion: BridgeProtocolVersion;
operation: 'execute_command';
Expand Down Expand Up @@ -203,13 +246,15 @@ export type WorkspaceToolRequest =
| WorkspaceSearchTextRequest
| WorkspaceListFilesRequest
| WorkspaceWriteFileRequest
| WorkspacePreviewEditRequest
| WorkspaceEditFileRequest
| WorkspaceExecuteCommandRequest;
export type WorkspaceToolResult =
| WorkspaceReadFileResult
| WorkspaceSearchTextResult
| WorkspaceListFilesResult
| WorkspaceWriteFileResult
| WorkspacePreviewEditResult
| WorkspaceEditFileResult
| WorkspaceExecuteCommandResult;

Expand Down Expand Up @@ -252,6 +297,16 @@ const WORKSPACE_EDIT_REQUEST_KEYS = new Set([
'oldText',
'newText',
'edits',
'expectedBaseSha256',
]);
const WORKSPACE_PREVIEW_EDIT_REQUEST_KEYS = new Set([
'protocolVersion',
'operation',
'workspaceId',
'path',
'oldText',
'newText',
'edits',
]);
const WORKSPACE_TEXT_EDIT_KEYS = new Set(['oldText', 'newText']);
const WORKSPACE_COMMAND_REQUEST_KEYS = new Set([
Expand Down Expand Up @@ -304,6 +359,17 @@ const WORKSPACE_EDIT_RESULT_KEYS = new Set([
'replacements',
'bytesWritten',
]);
const WORKSPACE_PREVIEW_EDIT_RESULT_KEYS = new Set([
'protocolVersion',
'operation',
'workspaceId',
'path',
'content',
'hasUtf8Bom',
'baseSha256',
'replacements',
'bytesWritten',
]);
const WORKSPACE_COMMAND_RESULT_KEYS = new Set([
'protocolVersion',
'operation',
Expand Down Expand Up @@ -352,6 +418,8 @@ export interface BridgeWorkerRegistrationResponse {
supportedWorkspaceWriteFileModes?: WorkspaceWriteFileMode[];
/** Edit modes this Code API can safely route to a capability-aware worker. */
supportedWorkspaceEditFileModes?: WorkspaceEditFileMode[];
/** Edit features this Code API can safely route to a capability-aware worker. */
supportedWorkspaceEditFileFeatures?: WorkspaceEditFileFeature[];
}

export interface BridgePairingRedemption {
Expand Down Expand Up @@ -652,10 +720,20 @@ export function isWorkspaceToolRequest(
typeof request.overwrite === 'boolean')
);
}
if (request.operation === 'preview_edit') {
return (
hasOnlyKeys(request, WORKSPACE_PREVIEW_EDIT_REQUEST_KEYS) &&
isSafePortableRelativePath(request.path) &&
isValidWorkspaceEditRequest(request)
);
}
if (request.operation === 'edit_file') {
return (
hasOnlyKeys(request, WORKSPACE_EDIT_REQUEST_KEYS) &&
isSafePortableRelativePath(request.path) &&
(request.expectedBaseSha256 === undefined ||
(typeof request.expectedBaseSha256 === 'string' &&
/^[a-f0-9]{64}$/.test(request.expectedBaseSha256))) &&
isValidWorkspaceEditRequest(request)
);
}
Expand Down Expand Up @@ -784,6 +862,26 @@ export function isWorkspaceToolResult(
);
}

if (request.operation === 'preview_edit') {
const replacements = request.edits?.length ?? 1;
const content = typeof result.content === 'string' ? result.content : null;
return (
hasOnlyKeys(result, WORKSPACE_PREVIEW_EDIT_RESULT_KEYS) &&
result.path === request.path &&
content !== null &&
Buffer.from(content).toString('utf8') === content &&
typeof result.hasUtf8Bom === 'boolean' &&
typeof result.baseSha256 === 'string' &&
/^[a-f0-9]{64}$/.test(result.baseSha256) &&
result.replacements === replacements &&
Number.isSafeInteger(result.bytesWritten) &&
Number(result.bytesWritten) ===
new TextEncoder().encode(content).byteLength +
(result.hasUtf8Bom ? 3 : 0) &&
Number(result.bytesWritten) <= BRIDGE_WORKSPACE_WRITE_MAX_BYTES
);
}

if (request.operation === 'execute_command') {
const stdout = typeof result.stdout === 'string' ? result.stdout : null;
const stderr = typeof result.stderr === 'string' ? result.stderr : null;
Expand Down Expand Up @@ -847,13 +945,14 @@ export function isValidBridgeWorkspaceToolCapabilities(
capabilities.protocolVersion !== BRIDGE_PROTOCOL_VERSION ||
!Array.isArray(capabilities.operations) ||
capabilities.operations.length < 1 ||
capabilities.operations.length > 6 ||
capabilities.operations.length > 7 ||
!capabilities.operations.every(
(operation) =>
operation === 'read_file' ||
operation === 'search_text' ||
operation === 'list_files' ||
operation === 'write_file' ||
operation === 'preview_edit' ||
operation === 'edit_file' ||
operation === 'execute_command',
) ||
Expand Down Expand Up @@ -885,7 +984,8 @@ export function isValidBridgeWorkspaceToolCapabilities(
(!Array.isArray(capabilities.editFileModes) ||
capabilities.editFileModes.length < 1 ||
capabilities.editFileModes.length > 2 ||
!capabilities.operations.includes('edit_file') ||
(!capabilities.operations.includes('edit_file') &&
!capabilities.operations.includes('preview_edit')) ||
!capabilities.editFileModes.every(
(mode) => mode === 'single' || mode === 'batch',
) ||
Expand All @@ -895,6 +995,16 @@ export function isValidBridgeWorkspaceToolCapabilities(
return false;
}

if (
capabilities.editFileFeatures !== undefined &&
(!Array.isArray(capabilities.editFileFeatures) ||
capabilities.editFileFeatures.length !== 1 ||
!capabilities.operations.includes('edit_file') ||
capabilities.editFileFeatures[0] !== 'expected_base_sha256')
) {
return false;
}

const workspaceIds = new Set<string>();
return capabilities.workspaces.every((workspace) => {
if (typeof workspace !== 'object' || workspace === null) return false;
Expand Down
Loading