From 6aa374148a4a297d966087a6274600d280220e6e Mon Sep 17 00:00:00 2001 From: Daniel Gordon Date: Wed, 15 Jul 2026 16:46:57 +0000 Subject: [PATCH 1/2] fix(server): open symlinked files (e.g. .env) in the file explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readFile rejected any in-tree file whose symlink target resolved outside the workspace root, so opening a `.env` symlinked into a git worktree from the main checkout (a common secrets-sharing pattern) failed with WorkspaceFilePathEscapeError — "error when opening the file". writeFile had no such check and already wrote straight through the symlink, so the file could be saved but not opened. Drop readFile's realpath-containment check so it follows symlinks like writeFile (and every editor) does. The lexical containment in resolveRelativePathWithinRoot still rejects `..`/absolute relativePaths, so that remains the security boundary; the explorer already lets users re-root anywhere (Home/root bar), so blocking in-tree symlinks added no real confidentiality boundary. Fixes #35 Co-Authored-By: Claude Opus 4.8 --- .../src/workspace/WorkspaceFileSystem.test.ts | 44 ++++++++++++------- .../src/workspace/WorkspaceFileSystem.ts | 33 ++++---------- 2 files changed, 35 insertions(+), 42 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993..469a606a413 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -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); }), ); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 39d6294ab90..36ac0141358 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -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) => @@ -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({ From 1933922cf04223b8a9361423156ad03ca52558fa Mon Sep 17 00:00:00 2001 From: Daniel Gordon Date: Wed, 15 Jul 2026 16:56:56 +0000 Subject: [PATCH 2/2] docs(agents): document fresh-worktree setup and fast test loop New t3code/* worktrees ship without node_modules; document the one-time pnpm install and the isolated single-file test/typecheck loop (plus the Effect.either/console.log gotchas) so future agents can reproduce server-behavior bugs in seconds instead of rediscovering the basics. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ab6f95cd9a3..18cbf3ac0d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.