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
27 changes: 27 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@
- If changing native mobile code, `vp run lint:mobile` must also pass.
- Use `vp test` for the built-in Vite+ test command and `vp run test` when you specifically need the `test` package script.

## Local Dev & Testing (start here in a fresh worktree)

A freshly-created `t3code/*` worktree ships **without `node_modules`** — install before running
anything:

- `pnpm install --frozen-lockfile --prefer-offline` — packages come from the shared pnpm store, so
this is ~10s and fully offline, not a cold download.

Then, to exercise a change without spinning up the whole app (fastest feedback loop):

- Run one test file from its package dir: `cd apps/server && npx vp test run src/workspace/WorkspaceFileSystem.test.ts`
(`vp test` is Vitest under the hood; add `run` for a single non-watch pass).
- Typecheck one package: `npx tsgo --noEmit -p apps/server/tsconfig.json`.
- Lint changed files' area: `npx vp lint apps/server/src/workspace/`.

Server behavior (filesystem, workspace, VCS, etc.) is very testable in isolation — copy an existing
`*.test.ts` in the same directory as a template rather than booting a server. `apps/server/src/workspace/*.test.ts`
are good examples: they use `it.layer(TestLayer)` + a `makeTempDir` helper to run the real Effect services
against a scratch dir, so you can reproduce a "does the server accept/reject this file?" bug in seconds.

Gotchas when writing these tests:

- This repo's Effect build has **no `Effect.either`** — use `Effect.exit` (with `Exit.isSuccess`/`Exit.isFailure`)
or `Effect.flip` to capture the error channel.
- `vp test` **suppresses `console.log`** on passing tests — assert the values you want to see (a failing
`expect(...).toEqual(...)` prints the actual object) instead of logging them.

## Project Snapshot

T3 Code is a minimal web GUI for using coding agents like Codex and Claude.
Expand Down
44 changes: 27 additions & 17 deletions apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,33 +88,43 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
}),
);

it.effect("rejects symlinks that resolve outside the workspace root", () =>
it.effect("follows in-tree symlinks whose target resolves outside the root", () =>
Effect.gen(function* () {
// A `.env` symlinked into a git worktree from the main checkout is the
// motivating case: the symlink lives inside the root the user is
// browsing, so — like every editor, and like writeFile already does —
// opening it should read the file it points at rather than error.
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
const outsideDir = yield* makeTempDir;
yield* writeTextFile(outsideDir, "secret.txt", "outside\n");
yield* fileSystem.symlink(
path.join(outsideDir, "secret.txt"),
path.join(cwd, "linked-secret.txt"),
);
yield* writeTextFile(outsideDir, "shared.env", "SECRET=1\n");
yield* fileSystem.symlink(path.join(outsideDir, "shared.env"), path.join(cwd, ".env"));

const result = yield* workspaceFileSystem.readFile({ cwd, relativePath: ".env" });

expect(result).toEqual({
relativePath: ".env",
contents: "SECRET=1\n",
byteLength: 9,
truncated: false,
});
}),
);

it.effect("still rejects `..` traversal in the requested path", () =>
Effect.gen(function* () {
// The lexical containment check remains the security boundary: a
// relativePath that escapes the root is rejected before any I/O.
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;

const error = yield* workspaceFileSystem
.readFile({ cwd, relativePath: "linked-secret.txt" })
.readFile({ cwd, relativePath: "../secret.txt" })
.pipe(Effect.flip);
const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);
const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));

expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
expect(error).toMatchObject({
workspaceRoot: cwd,
relativePath: "linked-secret.txt",
resolvedWorkspaceRoot,
resolvedPath,
});
expect("cause" in error).toBe(false);
expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
}),
);

Expand Down
33 changes: 8 additions & 25 deletions apps/server/src/workspace/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,14 @@ export const make = Effect.gen(function* () {
relativePath: input.relativePath,
});

const realWorkspaceRoot = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(workspaceRoot),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: workspaceRoot,
operation: "realpath-workspace-root",
cause,
}),
});
/* Follow symlinks to the file they point at, mirroring writeFile (which
* writes straight through target.absolutePath). The lexical containment in
* resolveRelativePathWithinRoot already rejects `..`/absolute relativePaths,
* so an in-tree entry the user can see in the explorer stays readable even
* when it is a symlink whose target resolves outside the root — e.g. a
* `.env` symlinked into a git worktree from the main checkout. Every editor
* opens symlinked files this way; refusing to only broke reads while writes
* already succeeded. */
const realTargetPath = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(target.absolutePath),
catch: (cause) =>
Expand All @@ -165,19 +161,6 @@ export const make = Effect.gen(function* () {
cause,
}),
});
const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
if (
relativeRealPath.startsWith(`..${path.sep}`) ||
relativeRealPath === ".." ||
path.isAbsolute(relativeRealPath)
) {
return yield* new WorkspaceFilePathEscapeError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedWorkspaceRoot: realWorkspaceRoot,
resolvedPath: realTargetPath,
});
}

return yield* Effect.acquireUseRelease(
Effect.tryPromise({
Expand Down
Loading