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
92 changes: 72 additions & 20 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4430,7 +4430,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
);

it.effect("preserves workspace rpc failure messages", () =>
it.effect("preserves structured workspace rpc failures", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand All @@ -4449,13 +4449,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {

const invalidWorkspace = path.join(workspaceDir, "missing-workspace");
const missingBrowseParent = path.join(workspaceDir, "missing-browse");
const sensitiveQuery = "authorization: Bearer secret-token";
const wsUrl = yield* getWsServerUrl("/ws");
const results = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
Effect.all({
search: client[WS_METHODS.projectsSearchEntries]({
cwd: invalidWorkspace,
query: "needle",
query: sensitiveQuery,
limit: 10,
}).pipe(Effect.result),
list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe(
Expand All @@ -4473,26 +4474,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
),
);

assertTrue(results.search._tag === "Failure");
assert.equal(
results.search.failure.message,
`Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`,
);
assertTrue(results.list._tag === "Failure");
if (
results.search._tag !== "Failure" ||
results.search.failure._tag !== "ProjectSearchEntriesError"
) {
assert.fail("Expected a ProjectSearchEntriesError");
}
const searchError = results.search.failure;
assert.equal(
results.list.failure.message,
`Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`,
searchError.message,
`Failed to search workspace entries in '${invalidWorkspace}'.`,
);
assertTrue(results.read._tag === "Failure");
assert.equal(searchError.cwd, invalidWorkspace);
assert.equal(searchError.queryLength, sensitiveQuery.length);
assert.notProperty(searchError, "query");
assert.notInclude(searchError.message, "Bearer");
assert.notInclude(searchError.message, "secret-token");
assert.equal(searchError.limit, 10);
assert.equal(searchError.failure, "workspace_root_not_found");
assert.equal(searchError.normalizedCwd, invalidWorkspace);
assert.isDefined(searchError.cause);

if (
results.list._tag !== "Failure" ||
results.list.failure._tag !== "ProjectListEntriesError"
) {
assert.fail("Expected a ProjectListEntriesError");
}
const listError = results.list.failure;
assert.equal(listError.message, `Failed to list workspace entries in '${invalidWorkspace}'.`);
assert.equal(listError.cwd, invalidWorkspace);
assert.equal(listError.failure, "workspace_root_not_found");
assert.equal(listError.normalizedCwd, invalidWorkspace);
assert.isDefined(listError.cause);

if (results.read._tag !== "Failure" || results.read.failure._tag !== "ProjectReadFileError") {
assert.fail("Expected a ProjectReadFileError");
}
const readError = results.read.failure;
assert.equal(
results.read.failure.message,
`Failed to read workspace file: Workspace file 'linked-outside.txt' resolves outside workspace root '${workspaceDir}': ${resolvedOutsideFile}`,
readError.message,
`Failed to read workspace file 'linked-outside.txt' in '${workspaceDir}'.`,
);
assertTrue(results.browse._tag === "Failure");
assert.equal(readError.cwd, workspaceDir);
assert.equal(readError.relativePath, "linked-outside.txt");
assert.equal(readError.failure, "resolved_path_outside_root");
assert.equal(readError.resolvedPath, resolvedOutsideFile);
assert.isDefined(readError.cause);

if (
results.browse._tag !== "Failure" ||
results.browse.failure._tag !== "FilesystemBrowseError"
) {
assert.fail("Expected a FilesystemBrowseError");
}
const browseError = results.browse.failure;
assert.equal(
results.browse.failure.message,
`Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`,
browseError.message,
`Failed to browse filesystem path './missing-browse/child' from '${workspaceDir}'.`,
);
assert.equal(browseError.cwd, workspaceDir);
assert.equal(browseError.partialPath, "./missing-browse/child");
assert.equal(browseError.failure, "read_directory_failed");
assert.equal(browseError.parentPath, missingBrowseParent);
assert.isDefined(browseError.cause);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

Expand Down Expand Up @@ -4573,12 +4618,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
).pipe(Effect.result),
);

assertTrue(result._tag === "Failure");
assertTrue(result.failure._tag === "ProjectWriteFileError");
if (result._tag !== "Failure" || result.failure._tag !== "ProjectWriteFileError") {
assert.fail("Expected a ProjectWriteFileError");
}
const writeError = result.failure;
assert.equal(
result.failure.message,
"Workspace file path must stay within the project root.",
writeError.message,
`Failed to write workspace file '../escape.txt' in '${workspaceDir}'.`,
);
assert.equal(writeError.cwd, workspaceDir);
assert.equal(writeError.relativePath, "../escape.txt");
assert.equal(writeError.failure, "workspace_path_outside_root");
assert.isDefined(writeError.cause);
assert.notProperty(writeError, "contents");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

Expand Down
140 changes: 96 additions & 44 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,17 @@ import {
OrchestrationGetSnapshotError,
OrchestrationGetTurnDiffError,
ORCHESTRATION_WS_METHODS,
type ProjectEntriesFailure,
type ProjectFileFailure,
type ProjectFileOperation,
ProjectListEntriesError,
ProjectReadFileError,
ProjectSearchEntriesError,
ProjectWriteFileError,
RelayClientInstallFailedError,
type RelayClientInstallProgressEvent,
OrchestrationReplayEventsError,
type FilesystemBrowseFailure,
FilesystemBrowseError,
AssetAccessError,
EnvironmentAuthorizationError,
Expand Down Expand Up @@ -108,19 +112,13 @@ import * as SessionStore from "./auth/SessionStore.ts";
import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
import * as RelayClient from "@t3tools/shared/relayClient";
const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError);

const nowIso = Effect.map(DateTime.now, DateTime.formatIso);

function unexpectedCompatibilityError(error: never): never {
throw new Error(`Unhandled compatibility error: ${String(error)}`);
}

/** Preserve pre-structured-error display behavior at the RPC boundary. */
function legacyPlatformFailureDescription(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}

/** Preserve the setup runner's broader pre-refactor message normalization. */
function legacySetupFailureDescription(cause: unknown): string {
if (
Expand All @@ -134,52 +132,99 @@ function legacySetupFailureDescription(cause: unknown): string {
return String(cause);
}

function workspaceEntriesCompatibilityDetail(
error: WorkspaceEntries.WorkspaceEntriesError,
): string {
function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): {
readonly failure: ProjectEntriesFailure;
readonly normalizedCwd?: string;
readonly timeout?: string;
readonly detail?: string;
} {
switch (error._tag) {
case "WorkspaceRootNotExistsError":
return `Workspace root does not exist: ${error.normalizedWorkspaceRoot}`;
return {
failure: "workspace_root_not_found",
normalizedCwd: error.normalizedWorkspaceRoot,
};
case "WorkspaceRootCreateFailedError":
return `Failed to create workspace root: ${error.normalizedWorkspaceRoot}`;
return {
failure: "workspace_root_create_failed",
normalizedCwd: error.normalizedWorkspaceRoot,
};
case "WorkspaceRootNotDirectoryError":
return `Workspace root is not a directory: ${error.normalizedWorkspaceRoot}`;
return {
failure: "workspace_root_not_directory",
normalizedCwd: error.normalizedWorkspaceRoot,
};
case "WorkspaceSearchIndexCreateFailed":
return `Failed to create the workspace search index for '${error.cwd}': ${error.reason}`;
return {
failure: "search_index_create_failed",
normalizedCwd: error.cwd,
detail: error.reason,
};
case "WorkspaceSearchIndexScanTimedOut":
return `Workspace search index for '${error.cwd}' did not finish scanning within ${error.timeout}`;
return {
failure: "search_index_scan_timed_out",
normalizedCwd: error.cwd,
timeout: error.timeout,
};
case "WorkspaceSearchIndexSearchFailed":
return `Workspace search failed for '${error.cwd}': ${error.reason}`;
return {
failure: "search_index_search_failed",
normalizedCwd: error.cwd,
detail: error.reason,
};
default:
return unexpectedCompatibilityError(error);
}
}

function workspaceBrowseCompatibilityDetail(
error: WorkspaceEntries.WorkspaceEntriesBrowseError,
): string {
function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntriesBrowseError): {
readonly failure: FilesystemBrowseFailure;
readonly parentPath?: string;
readonly platform?: string;
} {
switch (error._tag) {
case "WorkspaceEntriesWindowsPathUnsupportedError":
return "Windows-style paths are only supported on Windows.";
return { failure: "windows_path_unsupported", platform: error.platform };
case "WorkspaceEntriesCurrentProjectRequiredError":
return "Relative filesystem browse paths require a current project.";
return { failure: "current_project_required" };
case "WorkspaceEntriesReadDirectoryError":
return `Unable to browse '${error.parentPath}': ${legacyPlatformFailureDescription(error.cause)}`;
return { failure: "read_directory_failed", parentPath: error.parentPath };
default:
return unexpectedCompatibilityError(error);
}
}

function workspaceFileReadCompatibilityDetail(
error: WorkspaceFileSystem.WorkspaceFileSystemError,
): string {
function projectFileFailureContext(
error:
| WorkspaceFileSystem.WorkspaceFileSystemError
| WorkspacePaths.WorkspacePathOutsideRootError,
): {
readonly failure: ProjectFileFailure;
readonly resolvedPath?: string;
readonly resolvedWorkspaceRoot?: string;
readonly operation?: ProjectFileOperation;
readonly operationPath?: string;
} {
switch (error._tag) {
case "WorkspacePathOutsideRootError":
return { failure: "workspace_path_outside_root" };
case "WorkspaceFileSystemOperationError":
return legacyPlatformFailureDescription(error.cause);
return {
failure: "operation_failed",
resolvedPath: error.resolvedPath,
operation: error.operation,
operationPath: error.operationPath,
};
case "WorkspaceFilePathEscapeError":
return {
failure: "resolved_path_outside_root",
resolvedPath: error.resolvedPath,
resolvedWorkspaceRoot: error.resolvedWorkspaceRoot,
};
case "WorkspacePathNotFileError":
return { failure: "path_not_file", resolvedPath: error.resolvedPath };
case "WorkspaceBinaryFileError":
return error.message;
return { failure: "binary_file", resolvedPath: error.resolvedPath };
default:
return unexpectedCompatibilityError(error);
}
Expand Down Expand Up @@ -1275,7 +1320,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError(
(cause) =>
new ProjectSearchEntriesError({
message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`,
cwd: input.cwd,
queryLength: input.query.length,
limit: input.limit,
...projectEntriesFailureContext(cause),
cause,
}),
),
Expand All @@ -1289,7 +1337,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError(
(cause) =>
new ProjectListEntriesError({
message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`,
...input,
...projectEntriesFailureContext(cause),
cause,
}),
),
Expand All @@ -1300,28 +1349,30 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
observeRpcEffect(
WS_METHODS.projectsReadFile,
workspaceFileSystem.readFile(input).pipe(
Effect.mapError((cause) => {
const message = isWorkspacePathOutsideRootError(cause)
? "Workspace file path must stay within the project root."
: `Failed to read workspace file: ${workspaceFileReadCompatibilityDetail(cause)}`;
return new ProjectReadFileError({ message, cause });
}),
Effect.mapError(
(cause) =>
new ProjectReadFileError({
...input,
...projectFileFailureContext(cause),
cause,
}),
),
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.projectsWriteFile]: (input) =>
observeRpcEffect(
WS_METHODS.projectsWriteFile,
workspaceFileSystem.writeFile(input).pipe(
Effect.mapError((cause) => {
const message = isWorkspacePathOutsideRootError(cause)
? "Workspace file path must stay within the project root."
: "Failed to write workspace file";
return new ProjectWriteFileError({
message,
cause,
});
}),
Effect.mapError(
(cause) =>
new ProjectWriteFileError({
cwd: input.cwd,
relativePath: input.relativePath,
...projectFileFailureContext(cause),
cause,
}),
),
),
{ "rpc.aggregate": "workspace" },
),
Expand All @@ -1336,7 +1387,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError(
(cause) =>
new FilesystemBrowseError({
message: workspaceBrowseCompatibilityDetail(cause),
...input,
...filesystemBrowseFailureContext(cause),
cause,
}),
),
Expand Down
Loading
Loading