Skip to content

feat: shared folders - #137

Open
maxy-shpfy wants to merge 1 commit into
mainfrom
maxy/feat--shared-folders
Open

maxy-shpfy wants to merge 1 commit into
mainfrom
maxy/feat--shared-folders

Conversation

@maxy-shpfy

@maxy-shpfy maxy-shpfy commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Adds shared folders: server-managed "project" directories that live outside any single
session so multiple sessions can collaborate in one live workspace.

  • Bytes live under a new SHARED_ROOT/<id> directory; metadata is tracked in a new
    shared_folders SQLite table (migration 0024) with a SharedFolderStore
    (SQLite + in-memory implementations).
  • New REST surface under /api/shared-folders:
    • GET /api/shared-folders — list every shared folder (oldest first).
    • POST /api/shared-folders — create a folder and its backing directory; the creator is
      resolved from their identity when available, and the name defaults to Shared folder N.
  • Sessions can mount a shared folder via a new mount host-resource kind. A mount creates a
    one-way symlink at the session root pointing at the shared folder, so the agent reads and
    writes it with its ordinary file tools. mountName is an optional single-segment symlink
    name (defaults to a sanitized form of the folder's name).
  • Mounting is one-way — removing the catalog entry leaves the symlink in place; there is
    no unmount.
  • Session resource wiring now threads SharedFolderStore through the resource routes and
    seed deps.

Contracts (@tangent/shared), the embed types, and server docs/README are updated to
describe the feature and the new SHARED_ROOT config.

Screenshots

N/A

How to test

  1. pnpm --filter @tangent/server db:migrate to apply migration 0024.
  2. pnpm dev (local run points SHARED_ROOT at a gitignored .shared folder).
  3. POST /api/shared-folders with an optional { "name": "..." } and confirm a 201 with the
    created folder plus a backing directory under SHARED_ROOT/<id>.
  4. GET /api/shared-folders and confirm it lists the folder.
  5. Add a mount host resource to a session referencing the folderId; confirm a symlink
    appears at the session root and that two sessions mounting the same folder see each other's
    writes live.
  6. Run the server suite:
    • pnpm --filter @tangent/server exec tsx --test src/store/sqliteSharedFolderStore.test.ts
    • pnpm --filter @tangent/server exec tsx --test src/routes/sessions/resources.test.ts

Notes

  • Adds SHARED_ROOT env var — set it in deployed environments; pnpm dev defaults to
    .shared.
  • Mounting is intentionally one-way for this iteration (no unmount).

Stack created with GitHub Stacks CLI • Give Feedback 💬

@maxy-shpfy
maxy-shpfy added this pull request to stack #116 October 29, 2026 15:23
@maxy-shpfy
maxy-shpfy marked this pull request as ready for review October 29, 2026 18:50
@maxy-shpfy
maxy-shpfy removed this pull request from stack #116 October 29, 2026 18:53
@maxy-shpfy
maxy-shpfy force-pushed the maxy/feat--shared-folders branch from acc1bc7 to b26db1e Compare October 29, 2026 18:54
@maxy-shpfy
maxy-shpfy changed the base branch from maxy/feat--gate-local-agents-by-owner to maxy/fix--restore-session-envs-on-revive October 29, 2026 18:54
@maxy-shpfy
maxy-shpfy added this pull request to stack #139 October 29, 2026 18:54
@maxy-shpfy
maxy-shpfy marked this pull request as draft October 29, 2026 18:57
Base automatically changed from maxy/fix--restore-session-envs-on-revive to main October 30, 2026 00:01
@maxy-shpfy
maxy-shpfy force-pushed the maxy/feat--shared-folders branch 3 times, most recently from a29a3e7 to 9165713 Compare October 31, 2026 04:51
@maxy-shpfy
maxy-shpfy force-pushed the maxy/feat--shared-folders branch from 9165713 to ce3c98f Compare October 31, 2026 05:02
@maxy-shpfy
maxy-shpfy marked this pull request as ready for review October 31, 2026 06:40
@Mbeaulne
Mbeaulne self-requested a review November 2, 2026 21:22
const uri = `mount://${folder.id}`;
const resources = await deps.catalog.listForSession(sessionId);
const existing = resources.find((resource) => resource.uri === uri);
const mountName =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is an AI-generated code review comment.

Remote resource.add bypasses the mount-name guard (path traversal)

The only single-segment check on an explicit mountName is the zod refine in routes/sessions/schemas.ts, but RemoteEnvironmentGateway.handleResourceAdd passes request.input straight to applyResourceInput without hostResourceInputSchema (unlike externalAgentHandlers.ts). A remote environment authorized for the session can send mountName: "../<otherSessionId>/x" or "artifacts/x", and path.join(rootPath, mountName) in createMountSymlink plants a symlink outside the session root or inside a reserved / HTTP-served subtree — the reserved-name check is exact-match only.

The file kind has an authoritative server-side guard (resolveWithinUploads); suggest the same here:

if (
  mountName !== path.basename(mountName) ||
  mountName === "." ||
  mountName === ".." ||
  mountName.includes("\\")
) {
  throw badRequest("Mount name must be a single path segment");
}

and optionally hostResourceInputSchema.safeParse(request.input) in the gateway.

): CatalogInput {
return {
sessionId,
kind: "mount",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is an AI-generated code review comment.

Mounts aren't surfaced to agents

renderHostResourcesPreamble and /internal/resources both filter to kind === "host", so Prime never learns that <mountName>/ exists, that it's shared with other sessions, or that edits are live. Consider listing mount entries (name → path, plus a "shared across sessions, no locking" note) in the preamble and in read_resources — or note in the PR if that's a planned follow-up.

Comment thread apps/server/src/config.ts
* to, so edits in one session are visible to the others. Gitignored by default.
*/
export const SHARED_ROOT =
process.env.SHARED_ROOT ?? path.resolve(process.cwd(), ".shared");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is an AI-generated code review comment.

SHARED_ROOT isn't wired into pnpm dev / turbo

The README now says pnpm dev sets .shared, but the root dev script doesn't set SHARED_ROOT, so it falls back to apps/server/.shared (the other roots live at the repo root). It's also missing from globalPassThroughEnv in turbo.json, so with turbo's strict env mode a SHARED_ROOT set in the shell is dropped under pnpm dev.

Suggest adding SHARED_ROOT="$PWD/.shared" to the dev script and SHARED_ROOT to globalPassThroughEnv (#145's QMD_STATE_ROOT needs the same). Deployments should also set it on persistent storage alongside SESSIONS_ROOT; otherwise the shared_folders rows survive a redeploy while the folders don't, leaving dangling mount symlinks.

const resources = await deps.catalog.listForSession(sessionId);
const existing = resources.find((resource) => resource.uri === uri);
const mountName =
(existing?.meta?.mountName as string | undefined) ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is an AI-generated code review comment.

Unsafe cast on the stored mount name

meta is untyped catalog data; a type guard avoids feeding a non-string into path.join:

const stored = existing?.meta?.mountName;
const mountName =
  (typeof stored === "string" ? stored : undefined) ??
  requestedName ??
  sanitizeMountName(folder.name);


/** Body for `POST /api/shared-folders`. */
const createSharedFolderSchema = z.object({
name: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is an AI-generated code review comment.

Shared-folder name is unbounded

The name becomes the default mount name via sanitizeMountName, which doesn't strip NUL or cap length, so a very long name (or one containing \0) fails later in fs.symlink as a 500 at mount time rather than a 400 here. Suggest:

name: z
  .string()
  .trim()
  .min(1)
  .max(128)
  .refine((name) => !name.includes("\0"))
  .optional(),

plus the same .max() on the mountName schema in routes/sessions/schemas.ts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants