Skip to content
Draft
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

- Generic attachment originals and metadata are private `0600` files where POSIX permissions apply; provider-readable derivatives are `0400`. On Windows leave derivatives user-writable under Cafe's user-owned data-directory ACL instead of setting the read-only attribute, which would prevent safe cleanup. File names are inert display metadata and storage uses server-minted identifiers; Windows path separators in dropped names must never become server paths. Document extraction children use the current executable with `ELECTRON_RUN_AS_NODE=1` for packaged Electron backends and preserve only required Windows system-directory environment entries, not provider credentials or user-selected Node hooks. macOS/Linux retain the same isolated child behavior and their native permission checks.
- Windows Electron's Atrium reserves the visible native titlebar band above both its close button and provider filters. Gate that inset on Electron plus the Windows platform and the existing `wco` visibility class; derive it from native `titlebar-area-y`/`titlebar-area-height` geometry, with the desktop's 40px caption height as the fallback. Keep the modal full-window, its controls non-draggable, and its inset gaps responsive to interface scaling. macOS, Linux, ordinary browser clients, and fullscreen without native controls retain the original placement. Verify with `yarn workspace @cafecode/web test:browser src/components/atrium` (platform/scale layout cases) alongside the required full checks and forced desktop build.
- The read-only workspace observatory refuses Windows-ambiguous path segments on every platform so one rule holds everywhere: any `:` (NTFS alternate data streams such as `notes.txt:hidden`, and drive-relative forms such as `C:file`), trailing dots or spaces (Win32 strips them, so `secret.pem.` and `secret.pem` are two spellings of one file and a name-based denial could be bypassed through the unused spelling), reserved device names (`NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`, `CON`, `PRN`, `AUX`), wildcard/redirection characters, and control characters. Containment must be decided by `isAbsolute` on the result of `path.relative`, not by a drive-letter regexp, because a UNC escape such as `\\server\share` has no drive letter and would otherwise look contained; the win32 interpretation is consulted alongside the host one so the check also holds for win32-shaped paths on POSIX. macOS/Linux behaviour is unchanged except for sharing these same refusals. `O_NONBLOCK` is absent on Windows and is applied as `?? 0`, so the POSIX FIFO-safe open is a no-op there; POSIX named-pipe coverage stays POSIX-only because Windows does not create FIFOs through the filesystem namespace, and the Windows path is covered by the directory precheck instead. Verify with `yarn workspace @cafeai/cafe-code test src/workspace/Layers/WorkspaceObservatory.test.ts`.
- Observatory path schemas must preserve exact filename spelling on every platform. Do not trim paths before rejecting Windows trailing-space aliases, or turn a valid leading-space filename into a different file. Verify the decoded RPC path against a synthetic workspace containing both names.
- All Windows-specific requirements, implementation notes, workarounds, and verification guidance must live in this section. Do not scatter Windows-only rules through unrelated architecture, provider, packaging, or lifecycle sections; add or update bullets here instead.
- When making changes on Windows, be overly aware of three priorities: record every Windows-specific behavior or workaround in this section, never regress macOS/Linux behavior, and treat Windows as lower priority than macOS/Linux while still making the Windows path correct and maintainable.
- Keep Windows-only fixes documented in this section before implementation. Scope them so they do not change macOS or Linux behavior unless the task explicitly asks for a cross-platform change. Document how macOS/Linux behavior is preserved, and verify at least `yarn build:desktop` plus the relevant Windows command before considering a Windows fix complete.
Expand Down
118 changes: 118 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ import {
import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
import * as Socket from "effect/unstable/socket/Socket";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { vi } from "vitest";

const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
Expand Down Expand Up @@ -133,6 +136,8 @@ import {
} from "./environment/Services/ServerEnvironment.ts";
import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
import { makeWorkspaceObservatory } from "./workspace/Layers/WorkspaceObservatory.ts";
import { WorkspaceObservatory } from "./workspace/Services/WorkspaceObservatory.ts";
import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
import * as VcsDriver from "./vcs/VcsDriver.ts";
Expand Down Expand Up @@ -446,6 +451,13 @@ const makeBrowserOtlpPayload = (spanName: string) =>

const buildAppUnderTest = (options?: {
config?: Partial<ServerConfigShape>;
/**
* Synthetic workspace-root resolver for the read-only observatory. Supplying
* one is the only way a test can make the observatory observe anything, which
* mirrors production: the root always comes from the server side, never from
* the caller.
*/
workspaceObservatoryRoot?: (projectId: string) => string | null;
layers?: {
keybindings?: Partial<KeybindingsShape>;
providerRegistry?: Partial<ProviderRegistryShape>;
Expand Down Expand Up @@ -621,6 +633,15 @@ const buildAppUnderTest = (options?: {
Layer.provide(workspaceEntriesLayer),
),
ProjectFaviconResolverLive,
// Without an injected resolver this harness registers no projects, so
// every observatory request is denied as an unknown project. Path and
// payload behaviour is covered in depth by
// `workspace/Layers/WorkspaceObservatory.test.ts`.
Layer.succeed(WorkspaceObservatory)(
makeWorkspaceObservatory((projectId) =>
Effect.succeed(options?.workspaceObservatoryRoot?.(projectId) ?? null),
),
),
);
const gitWorkflowLayer = GitWorkflowService.layer.pipe(
Layer.provideMerge(vcsDriverRegistryLayer),
Expand Down Expand Up @@ -5594,4 +5615,101 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
/**
* End-to-end check of the read-only observatory over the real authenticated
* websocket RPC path, against a synthetic temporary workspace this test
* creates and removes. No real user project is involved.
*/
it.effect("serves bounded read-only workspace views over authenticated ws rpc", () =>
Effect.gen(function* () {
const observedProjectId = ProjectId.make("project-observatory-rpc");
const fixtureRoot = yield* Effect.promise(() =>
mkdtemp(join(tmpdir(), "cafe-observatory-rpc-")),
);
const workspaceRoot = join(fixtureRoot, "workspace");
yield* Effect.promise(async () => {
await mkdir(join(workspaceRoot, "src"), { recursive: true });
await mkdir(join(fixtureRoot, "outside"), { recursive: true });
await writeFile(join(workspaceRoot, "README.md"), "# Synthetic fixture", "utf8");
await writeFile(join(workspaceRoot, "src", "index.ts"), "export const value = 1;", "utf8");
await writeFile(join(workspaceRoot, ".env"), "API_KEY=synthetic-not-real", "utf8");
await writeFile(join(workspaceRoot, "session.pem"), "synthetic-not-a-key", "utf8");
await writeFile(join(fixtureRoot, "outside", "secret.txt"), "outside content", "utf8");
});

yield* buildAppUnderTest({
workspaceObservatoryRoot: (projectId) =>
projectId === observedProjectId ? workspaceRoot : null,
});
const wsUrl = yield* getWsServerUrl("/ws");

const listing = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.workspaceObservatoryTree]({ projectId: observedProjectId }),
),
);
assert.equal(listing.relativePath, "");
const listedNames = listing.entries.map((entry) => entry.name);
assert.include(listedNames, "README.md");
assert.include(listedNames, "src");
// Private-looking files are withheld from the listing and reported as such.
assert.equal(listedNames.includes(".env"), false);
assert.equal(listedNames.includes("session.pem"), false);
assert.equal(listing.redacted, true);

const file = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.workspaceObservatoryReadFile]({
projectId: observedProjectId,
relativePath: "src/index.ts",
}),
),
);
assert.equal(file.relativePath, "src/index.ts");
assert.equal(file.content, "export const value = 1;");
assert.equal(file.truncated, false);

const denialMessageFor = (relativePath: string, projectId = observedProjectId) =>
Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.workspaceObservatoryReadFile]({ projectId, relativePath }),
),
).pipe(
Effect.map(() => null),
Effect.catch((cause: { message?: string }) => Effect.succeed(cause.message ?? "")),
);

// A private file named directly is refused, not returned.
const envDenial = yield* denialMessageFor(".env");
assert.equal(envDenial, "Hidden, generated, or sensitive workspace items are not displayed.");
const pemDenial = yield* denialMessageFor("session.pem");
assert.equal(pemDenial, "Hidden, generated, or sensitive workspace items are not displayed.");

// Escaping the root, malformed input, and an unregistered project are all
// refused with coarse messages that never echo a filesystem path back.
const outsideDenial = yield* denialMessageFor("../outside/secret.txt");
assert.equal(outsideDenial, "Workspace path must stay within the project root.");
const absoluteDenial = yield* denialMessageFor(join(fixtureRoot, "outside", "secret.txt"));
assert.equal(absoluteDenial, "Workspace path must stay within the project root.");
const streamDenial = yield* denialMessageFor("README.md:hidden");
assert.equal(streamDenial, "Workspace path must stay within the project root.");
const unknownProjectDenial = yield* denialMessageFor(
"README.md",
ProjectId.make("project-never-registered"),
);
assert.equal(unknownProjectDenial, "Project is not part of this connected environment.");
for (const message of [
envDenial,
pemDenial,
outsideDenial,
absoluteDenial,
streamDenial,
unknownProjectDenial,
]) {
assert.equal((message ?? "").includes(fixtureRoot), false);
assert.equal((message ?? "").includes("secret"), false);
}
yield* Effect.promise(() => rm(fixtureRoot, { recursive: true, force: true }));
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
});
8 changes: 7 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResol
import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
import { WorkspaceObservatoryLive } from "./workspace/Layers/WorkspaceObservatory.ts";
import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
Expand Down Expand Up @@ -275,7 +276,12 @@ const ServerClientSettingsLayerLive = ServerClientSettingsLive.pipe(

const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
// Core Services
Layer.provideMerge(ThreadDetailSubscriptionRegistryLive),
// The read-only workspace observatory resolves its authoritative workspace
// root from the orchestration projection, so it is layered above
// `OrchestrationLayerLive` rather than inside `WorkspaceLayerLive`.
Layer.provideMerge(
Layer.mergeAll(ThreadDetailSubscriptionRegistryLive, WorkspaceObservatoryLive),
),
Layer.provideMerge(CheckpointingLayerLive),
Layer.provideMerge(SourceControlProviderRegistryLayerLive),
Layer.provideMerge(GitLayerLive),
Expand Down
Loading
Loading