Skip to content

Commit 1d199b8

Browse files
author
probe
committed
feat(webui): route the archive operations over the wire
The port members and the transport methods existed, and `host.ts` implemented them, but no operation was registered for either name — so a panel call came back `unknown_operation`. The seam was half-built in a way that typechecking cannot see, which is why it survived the earlier rounds. Both operations validate the same way their siblings do: a non-empty `workspaceDir` and `path`, and `prefix` as a non-empty string when present. The extraction's `destination` is checked here as the first gate rather than only inside the runtime, because a browser cannot name an absolute path, so a destination the client supplied verbatim is the one unvalidated link in the chain. The runtime re-validates regardless; this is the earlier gate, not a replacement. 56 files / 1077 tests pass; typecheck:server, typecheck:client and typecheck:test clean.
1 parent ecf0b33 commit 1d199b8

5 files changed

Lines changed: 80 additions & 5 deletions

File tree

‎packages/webui/src/server/operation/names.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const GET_WORKSPACE_REVIEW_FILE_CONTENT_OPERATION_NAME = "getWorkspaceRev
2929
export const SEARCH_WORKSPACE_REVIEW_DIFFS_OPERATION_NAME = "searchWorkspaceReviewDiffs" as const;
3030
export const READ_CANVAS_OPERATION_NAME = "readCanvas" as const;
3131
export const APPLY_CANVAS_OPERATION_NAME = "applyCanvas" as const;
32+
export const READ_WORKSPACE_ARCHIVE_OPERATION_NAME = "readWorkspaceArchive" as const;
33+
export const EXTRACT_WORKSPACE_ARCHIVE_OPERATION_NAME = "extractWorkspaceArchive" as const;
3234
export const CREATE_TERMINAL_OPERATION_NAME = "createTerminal" as const;
3335
export const LIST_TERMINALS_OPERATION_NAME = "listTerminals" as const;
3436
export const WRITE_TERMINAL_OPERATION_NAME = "writeTerminal" as const;

‎packages/webui/src/server/operation/operation-handlers.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export type WebuiOperationPort = Pick<
6161
| "searchWorkspaceReviewDiffs"
6262
| "readCanvas"
6363
| "applyCanvas"
64+
| "readWorkspaceArchive"
65+
| "extractWorkspaceArchive"
6466
| "sendMessage"
6567
| "enqueueMessage"
6668
| "resumeSession"
@@ -140,6 +142,12 @@ export function createOperationHandlers(
140142
applyCanvas: async (_context, body) => ({
141143
body: await port.applyCanvas(body),
142144
}),
145+
readWorkspaceArchive: async (_context, body) => ({
146+
body: await port.readWorkspaceArchive(body),
147+
}),
148+
extractWorkspaceArchive: async (_context, body) => ({
149+
body: await port.extractWorkspaceArchive(body),
150+
}),
143151
getWorkspaceEnvironment: async (_context, body) => ({
144152
body: await port.getWorkspaceEnvironment(body),
145153
}),

‎packages/webui/src/server/operation/operations.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { versionOperation, listSessionsOperation, listVisibleProjectsOperation, getSessionTreeOperation, createSessionOperation, getSessionOperation, getActiveTurnOperation } from "./session.js";
2-
import { listWorkspaceFileTreeOperation, browseWorkspaceDirsOperation, readWorkspaceFileOperation, getWorkspaceEnvironmentOperation, mutateWorkspaceGitOperation, getWorkspaceReviewSummaryOperation, listWorkspaceReviewFileDiffsOperation, getWorkspaceReviewFileContentOperation, searchWorkspaceReviewDiffsOperation, readCanvasOperation, applyCanvasOperation, createTerminalOperation, listTerminalsOperation, writeTerminalOperation, resizeTerminalOperation, disposeTerminalOperation, watchTerminalOperation } from "./workspace.js";
2+
import { listWorkspaceFileTreeOperation, browseWorkspaceDirsOperation, readWorkspaceFileOperation, getWorkspaceEnvironmentOperation, mutateWorkspaceGitOperation, getWorkspaceReviewSummaryOperation, listWorkspaceReviewFileDiffsOperation, getWorkspaceReviewFileContentOperation, searchWorkspaceReviewDiffsOperation, readCanvasOperation, applyCanvasOperation, readWorkspaceArchiveOperation, extractWorkspaceArchiveOperation, createTerminalOperation, listTerminalsOperation, writeTerminalOperation, resizeTerminalOperation, disposeTerminalOperation, watchTerminalOperation } from "./workspace.js";
33
import { getMessagesOperation, getSessionDiffOperation, getTurnDiffOperation, revertTurnDiffOperation, reapplyTurnDiffOperation, getSessionRewindPreviewOperation, rewindSessionOperation, editSessionMessageOperation } from "./messages.js";
44
import { isGoalEnabledOperation, getGoalOperation, createGoalOperation, patchGoalOperation, clearGoalOperation } from "./goal.js";
55
import { sendMessageOperation, enqueueMessageOperation, resumeSessionOperation } from "./interaction.js";
@@ -9,7 +9,7 @@ import { pluginManagementOperation } from "./plugin-management.js";
99
import { getPermissionModeOperation, setPermissionModeOperation } from "./permission-mode.js";
1010
import { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation } from "./provider.js";
1111
export { versionOperation, listSessionsOperation, listVisibleProjectsOperation, getSessionTreeOperation, createSessionOperation, getSessionOperation, getActiveTurnOperation } from "./session.js";
12-
export { listWorkspaceFileTreeOperation, browseWorkspaceDirsOperation, readWorkspaceFileOperation, getWorkspaceEnvironmentOperation, mutateWorkspaceGitOperation, getWorkspaceReviewSummaryOperation, listWorkspaceReviewFileDiffsOperation, getWorkspaceReviewFileContentOperation, searchWorkspaceReviewDiffsOperation, readCanvasOperation, applyCanvasOperation, createTerminalOperation, listTerminalsOperation, writeTerminalOperation, resizeTerminalOperation, disposeTerminalOperation, watchTerminalOperation } from "./workspace.js";
12+
export { listWorkspaceFileTreeOperation, browseWorkspaceDirsOperation, readWorkspaceFileOperation, getWorkspaceEnvironmentOperation, mutateWorkspaceGitOperation, getWorkspaceReviewSummaryOperation, listWorkspaceReviewFileDiffsOperation, getWorkspaceReviewFileContentOperation, searchWorkspaceReviewDiffsOperation, readCanvasOperation, applyCanvasOperation, readWorkspaceArchiveOperation, extractWorkspaceArchiveOperation, createTerminalOperation, listTerminalsOperation, writeTerminalOperation, resizeTerminalOperation, disposeTerminalOperation, watchTerminalOperation } from "./workspace.js";
1313
export { getMessagesOperation, getSessionDiffOperation, getTurnDiffOperation, revertTurnDiffOperation, reapplyTurnDiffOperation, getSessionRewindPreviewOperation, rewindSessionOperation, editSessionMessageOperation } from "./messages.js";
1414
export { isGoalEnabledOperation, getGoalOperation, createGoalOperation, patchGoalOperation, clearGoalOperation } from "./goal.js";
1515
export { sendMessageOperation, enqueueMessageOperation, resumeSessionOperation } from "./interaction.js";
@@ -48,6 +48,8 @@ export function createOperationRegistry(
4848
registerOperation(registry, { operation: readWorkspaceFileOperation, handle: handlers.readWorkspaceFile });
4949
registerOperation(registry, { operation: readCanvasOperation, handle: handlers.readCanvas });
5050
registerOperation(registry, { operation: applyCanvasOperation, handle: handlers.applyCanvas });
51+
registerOperation(registry, { operation: readWorkspaceArchiveOperation, handle: handlers.readWorkspaceArchive });
52+
registerOperation(registry, { operation: extractWorkspaceArchiveOperation, handle: handlers.extractWorkspaceArchive });
5153
registerOperation(registry, { operation: getWorkspaceEnvironmentOperation, handle: handlers.getWorkspaceEnvironment });
5254
registerOperation(registry, { operation: mutateWorkspaceGitOperation, handle: handlers.mutateWorkspaceGit });
5355
registerOperation(registry, { operation: getWorkspaceReviewSummaryOperation, handle: handlers.getWorkspaceReviewSummary });

‎packages/webui/src/server/operation/workspace.ts‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type { WebuiOperation, WebuiOperationValidation } from "./operation-contr
66
import { invalidBody } from "./operation-contract.js";
77
import type {
88
WebuiCanvasDocument,
9+
WebuiWorkspaceArchiveListing,
10+
WebuiWorkspaceArchiveExtractResult,
911
WebuiWorkspaceDirectoryListing,
1012
WebuiWorkspaceEnvironment,
1113
WebuiWorkspaceFile,
@@ -21,7 +23,7 @@ import {
2123
validateObjectBody,
2224
validateOptionalObjectBody,
2325
} from "./common.js";
24-
import { LIST_WORKSPACE_FILE_TREE_OPERATION_NAME, BROWSE_WORKSPACE_DIRS_OPERATION_NAME, READ_WORKSPACE_FILE_OPERATION_NAME, GET_WORKSPACE_ENVIRONMENT_OPERATION_NAME, MUTATE_WORKSPACE_GIT_OPERATION_NAME, GET_WORKSPACE_REVIEW_SUMMARY_OPERATION_NAME, LIST_WORKSPACE_REVIEW_FILE_DIFFS_OPERATION_NAME, GET_WORKSPACE_REVIEW_FILE_CONTENT_OPERATION_NAME, SEARCH_WORKSPACE_REVIEW_DIFFS_OPERATION_NAME, READ_CANVAS_OPERATION_NAME, APPLY_CANVAS_OPERATION_NAME, CREATE_TERMINAL_OPERATION_NAME, LIST_TERMINALS_OPERATION_NAME, WRITE_TERMINAL_OPERATION_NAME, RESIZE_TERMINAL_OPERATION_NAME, DISPOSE_TERMINAL_OPERATION_NAME, WATCH_TERMINAL_OPERATION_NAME } from "./names.js";
26+
import { LIST_WORKSPACE_FILE_TREE_OPERATION_NAME, BROWSE_WORKSPACE_DIRS_OPERATION_NAME, READ_WORKSPACE_FILE_OPERATION_NAME, GET_WORKSPACE_ENVIRONMENT_OPERATION_NAME, MUTATE_WORKSPACE_GIT_OPERATION_NAME, GET_WORKSPACE_REVIEW_SUMMARY_OPERATION_NAME, LIST_WORKSPACE_REVIEW_FILE_DIFFS_OPERATION_NAME, GET_WORKSPACE_REVIEW_FILE_CONTENT_OPERATION_NAME, SEARCH_WORKSPACE_REVIEW_DIFFS_OPERATION_NAME, READ_CANVAS_OPERATION_NAME, APPLY_CANVAS_OPERATION_NAME, READ_WORKSPACE_ARCHIVE_OPERATION_NAME, EXTRACT_WORKSPACE_ARCHIVE_OPERATION_NAME, CREATE_TERMINAL_OPERATION_NAME, LIST_TERMINALS_OPERATION_NAME, WRITE_TERMINAL_OPERATION_NAME, RESIZE_TERMINAL_OPERATION_NAME, DISPOSE_TERMINAL_OPERATION_NAME, WATCH_TERMINAL_OPERATION_NAME } from "./names.js";
2527

2628
/** Upper bound on one directory listing. A home directory can hold
2729
* thousands of folders and the picker only needs a browsable page. */
@@ -49,6 +51,17 @@ interface ApplyCanvasBody {
4951
readonly sessionId: string;
5052
readonly operation: Record<string, unknown>;
5153
}
54+
interface ReadWorkspaceArchiveBody {
55+
readonly workspaceDir: string;
56+
readonly path: string;
57+
readonly prefix?: string;
58+
}
59+
interface ExtractWorkspaceArchiveBody {
60+
readonly workspaceDir: string;
61+
readonly path: string;
62+
readonly destination: string;
63+
readonly prefix?: string;
64+
}
5265

5366
export const listWorkspaceFileTreeOperation: WebuiOperation<ListWorkspaceFileTreeBody, readonly WebuiWorkspaceFile[]> = {
5467
name: LIST_WORKSPACE_FILE_TREE_OPERATION_NAME,
@@ -234,8 +247,56 @@ export const applyCanvasOperation: WebuiOperation<ApplyCanvasBody, { readonly op
234247
};
235248
},
236249
};
237-
export const createTerminalOperation: WebuiOperation<Record<string, unknown>> = { name: CREATE_TERMINAL_OPERATION_NAME, validate: (body) => validateObjectBody(CREATE_TERMINAL_OPERATION_NAME, body) };
238-
export const listTerminalsOperation: WebuiOperation<Record<string, never>> = { name: LIST_TERMINALS_OPERATION_NAME, validate: (body) => body && typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0 ? { ok: true, body: {} } : { ok: false, code: WebuiErrorCode.invalidBody, message: "listTerminals body must be an empty object" } };
250+
export const readWorkspaceArchiveOperation: WebuiOperation<ReadWorkspaceArchiveBody, WebuiWorkspaceArchiveListing> = {
251+
name: READ_WORKSPACE_ARCHIVE_OPERATION_NAME,
252+
validate: (body): WebuiOperationValidation<ReadWorkspaceArchiveBody> => {
253+
const result = validateObjectBody(READ_WORKSPACE_ARCHIVE_OPERATION_NAME, body);
254+
if (!result.ok) return result;
255+
if (typeof result.body.workspaceDir !== "string" || !result.body.workspaceDir.trim())
256+
return { ok: false, code: WebuiErrorCode.invalidBody, message: "workspaceDir is required" };
257+
if (typeof result.body.path !== "string" || !result.body.path.trim())
258+
return { ok: false, code: WebuiErrorCode.invalidBody, message: "path is required" };
259+
if (result.body.prefix !== undefined && (typeof result.body.prefix !== "string" || !result.body.prefix.trim()))
260+
return { ok: false, code: WebuiErrorCode.invalidBody, message: "prefix must be a non-empty string when present" };
261+
return {
262+
ok: true,
263+
body: {
264+
workspaceDir: result.body.workspaceDir,
265+
path: result.body.path,
266+
...(typeof result.body.prefix === "string" ? { prefix: result.body.prefix } : {}),
267+
},
268+
};
269+
},
270+
};
271+
export const extractWorkspaceArchiveOperation: WebuiOperation<ExtractWorkspaceArchiveBody, WebuiWorkspaceArchiveExtractResult> = {
272+
name: EXTRACT_WORKSPACE_ARCHIVE_OPERATION_NAME,
273+
validate: (body): WebuiOperationValidation<ExtractWorkspaceArchiveBody> => {
274+
const result = validateObjectBody(EXTRACT_WORKSPACE_ARCHIVE_OPERATION_NAME, body);
275+
if (!result.ok) return result;
276+
if (typeof result.body.workspaceDir !== "string" || !result.body.workspaceDir.trim())
277+
return { ok: false, code: WebuiErrorCode.invalidBody, message: "workspaceDir is required" };
278+
if (typeof result.body.path !== "string" || !result.body.path.trim())
279+
return { ok: false, code: WebuiErrorCode.invalidBody, message: "path is required" };
280+
// Rejected here, before the runtime sees it: a browser cannot name an
281+
// absolute path, so a destination the client supplied verbatim would be
282+
// the one unvalidated path in the chain. The runtime re-validates
283+
// anyway — this is the first gate, not the only one.
284+
if (typeof result.body.destination !== "string" || !result.body.destination.trim())
285+
return { ok: false, code: WebuiErrorCode.invalidBody, message: "destination is required" };
286+
if (result.body.prefix !== undefined && (typeof result.body.prefix !== "string" || !result.body.prefix.trim()))
287+
return { ok: false, code: WebuiErrorCode.invalidBody, message: "prefix must be a non-empty string when present" };
288+
return {
289+
ok: true,
290+
body: {
291+
workspaceDir: result.body.workspaceDir,
292+
path: result.body.path,
293+
destination: result.body.destination,
294+
...(typeof result.body.prefix === "string" ? { prefix: result.body.prefix } : {}),
295+
},
296+
};
297+
},
298+
};
299+
export const createTerminalOperation: WebuiOperation<Record<string, unknown>> = { name: CREATE_TERMINAL_OPERATION_NAME, validate: (body) => validateObjectBody(CREATE_TERMINAL_OPERATION_NAME, body) };export const listTerminalsOperation: WebuiOperation<Record<string, never>> = { name: LIST_TERMINALS_OPERATION_NAME, validate: (body) => body && typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0 ? { ok: true, body: {} } : { ok: false, code: WebuiErrorCode.invalidBody, message: "listTerminals body must be an empty object" } };
239300
export const writeTerminalOperation: WebuiOperation<Record<string, unknown>> = { name: WRITE_TERMINAL_OPERATION_NAME, validate: (body) => validateObjectBody(WRITE_TERMINAL_OPERATION_NAME, body) };
240301
export const resizeTerminalOperation: WebuiOperation<Record<string, unknown>> = { name: RESIZE_TERMINAL_OPERATION_NAME, validate: (body) => validateObjectBody(RESIZE_TERMINAL_OPERATION_NAME, body) };
241302
export const disposeTerminalOperation: WebuiOperation<Record<string, unknown>> = { name: DISPOSE_TERMINAL_OPERATION_NAME, validate: (body) => validateObjectBody(DISPOSE_TERMINAL_OPERATION_NAME, body) };

‎packages/webui/src/server/service.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ export class WebuiService {
200200
searchWorkspaceReviewDiffs: (request) => this.port.searchWorkspaceReviewDiffs(request),
201201
readCanvas: (request) => this.port.readCanvas(request),
202202
applyCanvas: (request) => this.port.applyCanvas(request),
203+
readWorkspaceArchive: (request) => this.port.readWorkspaceArchive(request),
204+
extractWorkspaceArchive: (request) => this.port.extractWorkspaceArchive(request),
203205
sendMessage: (request, signal) => this.port.sendMessage(request, signal),
204206
enqueueMessage: (request) => this.port.enqueueMessage(request),
205207
resumeSession: (request, signal) =>

0 commit comments

Comments
 (0)