From ce3c98f32c06c3c334436842218f494d64c694ab Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 29 Oct 2026 08:20:58 -0700 Subject: [PATCH] feat: shared folders --- .gitignore | 3 + README.md | 8 +- apps/server/src/config.ts | 9 + .../src/conversation/hostResources.test.ts | 129 +- apps/server/src/conversation/hostResources.ts | 141 ++- apps/server/src/index.ts | 23 +- .../remoteEnvironmentGatewayResources.test.ts | 9 +- apps/server/src/routes/attach.test.ts | 27 +- .../src/routes/sessions/createSession.test.ts | 41 + apps/server/src/routes/sessions/handlers.ts | 9 +- apps/server/src/routes/sessions/index.ts | 4 + .../src/routes/sessions/resources.test.ts | 3 + apps/server/src/routes/sessions/resources.ts | 9 +- apps/server/src/routes/sessions/schemas.ts | 13 + apps/server/src/routes/sharedFolders.ts | 62 + .../db/migrations/0025_pretty_warpath.sql | 6 + .../db/migrations/meta/0025_snapshot.json | 1042 +++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 18 +- .../src/store/inMemorySharedFolderStore.ts | 43 + apps/server/src/store/sharedFolderStore.ts | 34 + .../src/store/sqliteSharedFolderStore.test.ts | 64 + .../src/store/sqliteSharedFolderStore.ts | 87 ++ apps/web/src/features/chat/model/resources.ts | 3 + docs/server/index.md | 13 +- docs/server/sessions-and-storage.md | 73 +- docs/server/ui-server-protocol.md | 22 +- packages/embed-react/README.md | 34 +- packages/embed-react/src/types.ts | 12 +- packages/shared/src/contracts.ts | 48 +- 30 files changed, 1961 insertions(+), 35 deletions(-) create mode 100644 apps/server/src/routes/sharedFolders.ts create mode 100644 apps/server/src/store/db/migrations/0025_pretty_warpath.sql create mode 100644 apps/server/src/store/db/migrations/meta/0025_snapshot.json create mode 100644 apps/server/src/store/inMemorySharedFolderStore.ts create mode 100644 apps/server/src/store/sharedFolderStore.ts create mode 100644 apps/server/src/store/sqliteSharedFolderStore.test.ts create mode 100644 apps/server/src/store/sqliteSharedFolderStore.ts diff --git a/.gitignore b/.gitignore index 1c8dac35..3a4447d2 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ ui-dist # Saved agent bundles backing the marketplace .agent-bundles +# Server-managed shared folders sessions mount into their root +.shared + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/README.md b/README.md index 30bfd110..9ceba0b8 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ pnpm dev ``` `pnpm dev` sets the session/bundle/memory paths to local gitignored folders (`.sessions`, -`.agent-bundles`, `.memory`) so a checkout runs without extra setup. +`.agent-bundles`, `.memory`, `.shared`) so a checkout runs without extra setup. Copy `.env.example` to `.env` for local-only provider values. Keep real deployment URLs, cookies, proxy credentials, and auth endpoints out of committed files; inject them @@ -54,6 +54,9 @@ pnpm seed # installs bundles from examples builder, reviewer, …) that run in parallel in a shared workspace and report back. - **Memory** — Per-session memory plus a global, cross-session store the agent reads and writes. Memory changes the agent suggests are confirmed by you before they stick. +- **Shared folders** — Server-managed "project" folders that live outside any one session. + Mount one into a session (a symlink at its root) and several sessions can work in the same + live workspace. Mounting is one-way — there is no unmount. - **Triggers** — Schedule work on a cron or interval, or fire it from an inbound callback. - **Artifacts** — Files the agent creates (HTML, code, PDFs, …) are surfaced and pinnable in the UI. @@ -93,7 +96,7 @@ agent per session and spawns sub-agents on demand, streaming their events to the Socket.IO rooms. - **REST API** (`/api/*`) — sessions CRUD, file uploads and artifact serving, agent bundles, - global memory, and current-user lookup. + shared folders, global memory, and current-user lookup. - **Internal API** (`/internal/*`) — called by agents (guarded by a bearer token) to spawn and message sub-agents, read/write memory, manage triggers, and make sandboxed egress requests through an allowlist. @@ -131,6 +134,7 @@ the full list and defaults): | `SESSIONS_ROOT` | Root directory for per-session workspaces | | `SESSIONS_DB` | SQLite metadata database file | | `AGENT_BUNDLES_ROOT` | Bundle marketplace storage | +| `SHARED_ROOT` | Root directory for cross-session shared folders (mounts) | | `GLOBAL_MEMORY_DIR` | Global (cross-session) memory store | | `PI_BIN` | Path to the `pi` agent executable | | `PI_PROVIDER` / `PI_MODEL` / `PI_THINKING` | Default LLM provider, model, and thinking level | diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 62256cac..b511debf 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -45,6 +45,15 @@ export const AGENT_BUNDLES_ROOT = process.env.AGENT_BUNDLES_ROOT ?? path.resolve(process.cwd(), ".agent-bundles"); +/** + * Root directory holding server-managed shared folders — the cross-session + * "project" folders a session mounts into its root by symlink. Each folder gets + * its own subdirectory (`SHARED_ROOT/`) that any number of sessions may link + * 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"); + /** * Name of the per-session subdirectory whose contents are served over HTTP as * artifacts (images, generated HTML pages, reports). Agents write user-facing diff --git a/apps/server/src/conversation/hostResources.test.ts b/apps/server/src/conversation/hostResources.test.ts index c082ef0e..3e575362 100644 --- a/apps/server/src/conversation/hostResources.test.ts +++ b/apps/server/src/conversation/hostResources.test.ts @@ -1,5 +1,13 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { after, test } from "node:test"; @@ -8,8 +16,10 @@ import { after, test } from "node:test"; // modules that read config at load time, so writes never touch the repo. const ROOT = mkdtempSync(path.join(tmpdir(), "host-res-")); const MEM = mkdtempSync(path.join(tmpdir(), "host-mem-")); +const SHARED = mkdtempSync(path.join(tmpdir(), "host-shared-")); process.env.SESSIONS_ROOT = ROOT; process.env.GLOBAL_MEMORY_DIR = MEM; +process.env.SHARED_ROOT = SHARED; const { applyResourceInput, memoryScopeFromUri, removeResourceByUri } = await import("./hostResources.ts"); @@ -18,6 +28,8 @@ const { MemoryManager } = await import("../pi/memory.ts"); const { HostResourcePreamble } = await import("../pi/hostResourcePreamble.ts"); const { InMemoryResourceStore } = await import("../store/inMemoryResourceStore.ts"); +const { InMemorySharedFolderStore } = + await import("../store/inMemorySharedFolderStore.ts"); const { MEMORY_AUTHOR } = await import("@tangent/shared/contracts.ts"); import type { SessionStore } from "../store/sessionStore.ts"; @@ -29,11 +41,17 @@ const store = { listAgents: async () => [] } as unknown as SessionStore; after(() => { rmSync(ROOT, { recursive: true, force: true }); rmSync(MEM, { recursive: true, force: true }); + rmSync(SHARED, { recursive: true, force: true }); }); function deps() { const catalog = new ResourceCatalog(new InMemoryResourceStore()); - return { store, memory: new MemoryManager(), catalog }; + return { + store, + memory: new MemoryManager(), + catalog, + sharedFolders: new InMemorySharedFolderStore(), + }; } function sessionRoot() { @@ -287,3 +305,110 @@ test("a file seed does not appear in the host-resources preamble", async () => { await preamble.refresh("sfile4"); assert.equal(preamble.get("sfile4"), ""); }); + +test("a mount input symlinks the shared folder into the session root", async () => { + const d = deps(); + const rootPath = sessionRoot(); + const folder = await d.sharedFolders.createSharedFolder({ + name: "project-x", + }); + writeFileSync(path.join(SHARED, folder.id, "hello.txt"), "shared bytes"); + + const resource = await applyResourceInput(d, "smount1", rootPath, { + kind: "mount", + folderId: folder.id, + }); + + assert.equal(resource.kind, "mount"); + assert.equal(resource.uri, `mount://${folder.id}`); + assert.equal(resource.name, "project-x"); + assert.equal(resource.meta?.mountName, "project-x"); + + const linkPath = path.join(rootPath, "project-x"); + assert.equal(lstatSync(linkPath).isSymbolicLink(), true); + assert.equal(readlinkSync(linkPath), path.join(SHARED, folder.id)); + // The shared bytes are reachable through the symlink from the session root. + assert.equal( + readFileSync(path.join(linkPath, "hello.txt"), "utf8"), + "shared bytes", + ); +}); + +test("an explicit mountName is honored", async () => { + const d = deps(); + const rootPath = sessionRoot(); + const folder = await d.sharedFolders.createSharedFolder({ name: "data" }); + + await applyResourceInput(d, "smount2", rootPath, { + kind: "mount", + folderId: folder.id, + mountName: "proj", + }); + + assert.equal(lstatSync(path.join(rootPath, "proj")).isSymbolicLink(), true); +}); + +test("re-applying the same mount keeps one symlink and one row", async () => { + const d = deps(); + const rootPath = sessionRoot(); + const folder = await d.sharedFolders.createSharedFolder({ name: "repeat" }); + + await applyResourceInput(d, "smount3", rootPath, { + kind: "mount", + folderId: folder.id, + }); + await applyResourceInput(d, "smount3", rootPath, { + kind: "mount", + folderId: folder.id, + }); + + assert.equal((await d.catalog.listForSession("smount3")).length, 1); + assert.equal(lstatSync(path.join(rootPath, "repeat")).isSymbolicLink(), true); +}); + +test("a mount with an unknown folderId is rejected", async () => { + const d = deps(); + const rootPath = sessionRoot(); + + await assert.rejects( + applyResourceInput(d, "smount4", rootPath, { + kind: "mount", + folderId: "does-not-exist", + }), + /Unknown shared folder/, + ); + assert.equal((await d.catalog.listForSession("smount4")).length, 0); +}); + +test("a reserved mountName is rejected and writes no symlink", async () => { + const d = deps(); + const rootPath = sessionRoot(); + const folder = await d.sharedFolders.createSharedFolder({ name: "res" }); + + await assert.rejects( + applyResourceInput(d, "smount5", rootPath, { + kind: "mount", + folderId: folder.id, + mountName: "artifacts", + }), + /reserved/, + ); + assert.equal(existsSync(path.join(rootPath, "artifacts")), false); + assert.equal((await d.catalog.listForSession("smount5")).length, 0); +}); + +test("removing a mount drops the row but leaves the symlink (one-way)", async () => { + const d = deps(); + const rootPath = sessionRoot(); + const folder = await d.sharedFolders.createSharedFolder({ name: "oneway" }); + const uri = `mount://${folder.id}`; + + await applyResourceInput(d, "smount6", rootPath, { + kind: "mount", + folderId: folder.id, + }); + await removeResourceByUri(d, "smount6", rootPath, uri); + + assert.equal((await d.catalog.listForSession("smount6")).length, 0); + assert.equal(lstatSync(path.join(rootPath, "oneway")).isSymbolicLink(), true); +}); diff --git a/apps/server/src/conversation/hostResources.ts b/apps/server/src/conversation/hostResources.ts index bc240041..34130bcc 100644 --- a/apps/server/src/conversation/hostResources.ts +++ b/apps/server/src/conversation/hostResources.ts @@ -5,11 +5,14 @@ import { type HostResourceInput, MEMORY_AUTHOR, type MemoryScope, + type SharedFolder, } from "@tangent/shared/contracts.ts"; import { ARTIFACTS_DIRNAME, + GLOBAL_MEMORY_FILENAME, MAX_FILE_RESOURCE_BYTES, + SESSION_MEMORY_FILENAME, UPLOADS_DIRNAME, } from "../config.ts"; import { badRequest } from "../middleware/errorHandler.ts"; @@ -17,6 +20,10 @@ import type { MemoryManager } from "../pi/memory.ts"; import { isWithin } from "../routes/sessions/utils.ts"; import type { CatalogInput, Resource } from "../store/resourceStore.ts"; import type { SessionStore } from "../store/sessionStore.ts"; +import { + sharedFolderPath, + type SharedFolderStore, +} from "../store/sharedFolderStore.ts"; import { orchestratorConversationFor } from "./participantRegistry.ts"; import type { ResourceCatalog } from "./resourceCatalog.ts"; @@ -25,6 +32,29 @@ export interface ResourceSeedDeps { store: SessionStore; memory: MemoryManager; catalog: ResourceCatalog; + /** Resolves the shared folder a `mount` input symlinks into the session. */ + sharedFolders: SharedFolderStore; +} + +/** + * Names a mount symlink may not take at the session root — the files and folders + * the session provisions and serves — so a mount can never shadow `.tangent/`, + * the memory files, or the HTTP-served subtrees. + */ +const RESERVED_MOUNT_NAMES = new Set([ + ARTIFACTS_DIRNAME, + UPLOADS_DIRNAME, + ".tangent", + "AGENTS.md", + SESSION_MEMORY_FILENAME, + GLOBAL_MEMORY_FILENAME, +]); + +/** A single, traversal-free path segment derived from a shared folder's name. */ +function sanitizeMountName(name: string): string { + const base = path.basename(name).replace(/[/\\]/g, "_"); + const cleaned = base.replace(/^\.+/, "").trim(); + return cleaned || "shared"; } /** The catalog entry a memory store stands for, keyed by its scope. */ @@ -116,6 +146,104 @@ function fileResource( }; } +/** The catalog entry a mounted shared folder stands for, keyed by its uri. */ +function mountResource( + sessionId: string, + folder: SharedFolder, + mountName: string, + authorParticipantId: string | undefined, +): CatalogInput { + return { + sessionId, + kind: "mount", + name: folder.name, + uri: `mount://${folder.id}`, + authorParticipantId, + meta: { + sharedFolderId: folder.id, + mountName, + target: sharedFolderPath(folder.id), + }, + }; +} + +/** + * Creates the symlink that mounts a shared folder at `mountName` under the + * session root. Idempotent: a link already pointing at `target` is left as-is, + * so re-applying the same mount is a no-op; a different file or folder already + * occupying the name is a 400 rather than a silent overwrite. + */ +async function createMountSymlink( + rootPath: string, + mountName: string, + target: string, +): Promise { + const linkPath = path.join(rootPath, mountName); + const existing = await fs.lstat(linkPath).catch(() => null); + if (existing) { + if (existing.isSymbolicLink()) { + const current = await fs.readlink(linkPath).catch(() => null); + if (current === target) return; + } + throw badRequest("A file or folder already exists at the mount name"); + } + await fs.symlink(target, linkPath, "dir"); +} + +/** + * Resolves a shared folder and symlinks it into the session root, returning the + * mount name used. Reuses an existing mount's name so re-applying never spawns a + * second link under a different name; rejects an unknown folder or a reserved + * name with a 400. + */ +async function mountSharedFolder( + deps: ResourceSeedDeps, + sessionId: string, + rootPath: string, + folder: SharedFolder, + requestedName: string | undefined, +): Promise { + const uri = `mount://${folder.id}`; + const resources = await deps.catalog.listForSession(sessionId); + const existing = resources.find((resource) => resource.uri === uri); + const mountName = + (existing?.meta?.mountName as string | undefined) ?? + requestedName ?? + sanitizeMountName(folder.name); + if (RESERVED_MOUNT_NAMES.has(mountName)) { + throw badRequest(`"${mountName}" is a reserved name; choose another`); + } + await createMountSymlink(rootPath, mountName, sharedFolderPath(folder.id)); + return mountName; +} + +/** + * Mounts a shared folder into the session (symlink) and catalogs the `mount` + * entry in the orchestrator's Conversation. Rejects an unknown folder with a 400. + */ +async function applyMount( + deps: ResourceSeedDeps, + sessionId: string, + rootPath: string, + conversationId: string, + input: Extract, + authorParticipantId: string | undefined, +): Promise { + const folder = await deps.sharedFolders.getSharedFolder(input.folderId); + if (!folder) throw badRequest("Unknown shared folder"); + const mountName = await mountSharedFolder( + deps, + sessionId, + rootPath, + folder, + input.mountName, + ); + return deps.catalog.catalogIn( + conversationId, + mountResource(sessionId, folder, mountName, authorParticipantId), + ); +} + /** The memory scope a `memory://` uri names, or null for any other uri. */ export function memoryScopeFromUri(uri: string): MemoryScope | null { if (uri === "memory://session") return "session"; @@ -161,6 +289,16 @@ export async function applyResourceInput( fileResource(sessionId, uri, input, authorParticipantId), ); } + if (input.kind === "mount") { + return applyMount( + deps, + sessionId, + rootPath, + conversationId, + input, + authorParticipantId, + ); + } return deps.catalog.catalogIn( conversationId, hostResource(sessionId, input, authorParticipantId), @@ -194,7 +332,8 @@ async function unlinkManagedFile( * Removes a catalogued resource by uri, cascading its references. Removing a * memory store's entry also empties the store, so `read_memory` and the catalog * agree; removing a host `file` also deletes its bytes so the workspace rescan - * can't resurrect it. + * can't resurrect it. A `mount` entry is one-way: dropping its row leaves the + * symlink in place, so the agent keeps its access to the shared folder. */ export async function removeResourceByUri( deps: ResourceSeedDeps, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index dbabf5ea..da9e2104 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -50,6 +50,7 @@ import { createInternalTriggersRouter } from "./routes/internalTriggers.ts"; import { createMcpRelayRouter } from "./routes/mcp.ts"; import { createMeRouter } from "./routes/me.ts"; import { createSessionsRouter } from "./routes/sessions/index.ts"; +import { createSharedFoldersRouter } from "./routes/sharedFolders.ts"; import { RunRegistry } from "./runs/runRegistry.ts"; import { createAgentEventHandler, @@ -77,6 +78,7 @@ import { SqliteReactorStore } from "./store/sqliteReactorStore.ts"; import { SqliteResourceStore } from "./store/sqliteResourceStore.ts"; import { SqliteRunStore } from "./store/sqliteRunStore.ts"; import { SqliteSessionStore } from "./store/sqliteSessionStore.ts"; +import { SqliteSharedFolderStore } from "./store/sqliteSharedFolderStore.ts"; // Opening the DB applies pending drizzle-kit migrations on startup. The single // shared connection backs every store: session metadata for the REST routes and @@ -92,6 +94,10 @@ const participants = new SqliteParticipantStore(db); // stay authoritative and this table tracks them so a resource is citable. const resourceStore = new SqliteResourceStore(db); const store = new SqliteSessionStore(db, resourceStore); +// Registry of server-managed shared folders: the cross-session "project" folders +// a session mounts (by symlink) into its root. Metadata lives in SQLite; the +// bytes live under SHARED_ROOT. +const sharedFolderStore = new SqliteSharedFolderStore(db); // Filesystem-backed marketplace of saved agent bundles. const agentBundleStore = new FileAgentBundleStore(); // Who besides the ADMIN_EMAILS admins may upload and delete those bundles. @@ -292,7 +298,12 @@ remoteGateway.useContextProjection(contextEngine, memberships); // A remote resource add/list/read uses the same catalog write path, preamble // refresher, and room notifier the HTTP resource routes hold. remoteGateway.useResources({ - seed: { store, memory, catalog: resourceCatalog }, + seed: { + store, + memory, + catalog: resourceCatalog, + sharedFolders: sharedFolderStore, + }, catalog: resourceCatalog, hostPreamble: hostResourcePreamble, emitResourcesUpdated, @@ -409,6 +420,7 @@ app.use( agentBundleStore, participantService, resourceCatalog, + sharedFolderStore, memory, hostResourcePreamble, emitResourcesUpdated, @@ -431,6 +443,8 @@ app.use( createAgentBundlesRouter(agentBundleStore, bundleEditorStore), ); app.use("/api/bundle-editors", createBundleEditorsRouter(bundleEditorStore)); +// Registry of cross-session shared folders a session can mount into its root. +app.use("/api/shared-folders", createSharedFoldersRouter(sharedFolderStore)); app.use("/api/global-memory", createGlobalMemoryRouter(memory)); // Public MCP relay dialed by an external client; per-channel bearer presented // on Authorization (or Mcp-Authorization behind a Basic-auth gateway). @@ -455,7 +469,12 @@ app.use( app.use( "/api/attach", createAttachRouter(externalGateway, store, connectors, conversations, { - seed: { store, memory, catalog: resourceCatalog }, + seed: { + store, + memory, + catalog: resourceCatalog, + sharedFolders: sharedFolderStore, + }, catalog: resourceCatalog, hostPreamble: hostResourcePreamble, emitResourcesUpdated, diff --git a/apps/server/src/remote/remoteEnvironmentGatewayResources.test.ts b/apps/server/src/remote/remoteEnvironmentGatewayResources.test.ts index 6d75ec41..093ad9eb 100644 --- a/apps/server/src/remote/remoteEnvironmentGatewayResources.test.ts +++ b/apps/server/src/remote/remoteEnvironmentGatewayResources.test.ts @@ -26,6 +26,8 @@ const { RunRegistry } = await import("../runs/runRegistry.ts"); const { InMemoryRunStore } = await import("../store/inMemoryRunStore.ts"); const { InMemoryResourceStore } = await import("../store/inMemoryResourceStore.ts"); +const { InMemorySharedFolderStore } = + await import("../store/inMemorySharedFolderStore.ts"); const { InMemorySessionStore } = await import("../store/inMemorySessionStore.ts"); const { InMemoryParticipantStore } = @@ -115,7 +117,12 @@ function makeHarness() { scoped, ); gateway.useResources({ - seed: { store, memory, catalog }, + seed: { + store, + memory, + catalog, + sharedFolders: new InMemorySharedFolderStore(), + }, catalog, hostPreamble: new HostResourcePreamble(catalog), emitResourcesUpdated: () => {}, diff --git a/apps/server/src/routes/attach.test.ts b/apps/server/src/routes/attach.test.ts index 2f7c7eeb..52528917 100644 --- a/apps/server/src/routes/attach.test.ts +++ b/apps/server/src/routes/attach.test.ts @@ -24,6 +24,8 @@ const { InMemoryParticipantStore } = await import("../store/inMemoryParticipantStore.ts"); const { InMemoryResourceStore } = await import("../store/inMemoryResourceStore.ts"); +const { InMemorySharedFolderStore } = + await import("../store/inMemorySharedFolderStore.ts"); const { ResourceCatalog } = await import("../conversation/resourceCatalog.ts"); const { HostResourcePreamble } = await import("../pi/hostResourcePreamble.ts"); const { MemoryManager } = await import("../pi/memory.ts"); @@ -37,6 +39,23 @@ import type { ConversationEventSink } from "../pi/types.ts"; const SECRET = "attach-test-secret"; +/** The resource-route deps an attach server runs with, over in-memory stores. */ +function attachResources(store: InstanceType) { + const catalog = new ResourceCatalog(new InMemoryResourceStore()); + const resources = { + seed: { + store, + memory: new MemoryManager(), + catalog, + sharedFolders: new InMemorySharedFolderStore(), + }, + catalog, + hostPreamble: new HostResourcePreamble(catalog), + emitResourcesUpdated: () => {}, + }; + return { catalog, resources }; +} + const cleanups: (() => void)[] = []; after(() => { for (const cleanup of cleanups) cleanup(); @@ -68,13 +87,7 @@ async function serve() { post: async () => {}, } as unknown as ConversationRouter; - const catalog = new ResourceCatalog(new InMemoryResourceStore()); - const resources = { - seed: { store, memory: new MemoryManager(), catalog }, - catalog, - hostPreamble: new HostResourcePreamble(catalog), - emitResourcesUpdated: () => {}, - }; + const { catalog, resources } = attachResources(store); const app = express(); app.use(express.json()); diff --git a/apps/server/src/routes/sessions/createSession.test.ts b/apps/server/src/routes/sessions/createSession.test.ts index 2c0c94c4..e936eca5 100644 --- a/apps/server/src/routes/sessions/createSession.test.ts +++ b/apps/server/src/routes/sessions/createSession.test.ts @@ -10,6 +10,7 @@ import type { PiAgentManager } from "../../pi/piAgentManager.ts"; import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; +import type { SharedFolderStore } from "../../store/sharedFolderStore.ts"; import { handleCreateSession, type SessionCreateDeps } from "./handlers.ts"; import { createSessionSchema } from "./schemas.ts"; @@ -37,6 +38,45 @@ test("createSessionSchema rejects blank create requests", () => { ); }); +test("createSessionSchema accepts a mount resource on create", () => { + assert.equal( + createSessionSchema.safeParse({ + bundleId: "tangle", + resources: [{ kind: "mount", folderId: "folder-1" }], + }).success, + true, + ); + assert.equal( + createSessionSchema.safeParse({ + bundleId: "tangle", + resources: [ + { kind: "mount", folderId: "folder-1", mountName: "project" }, + ], + }).success, + true, + ); +}); + +test("a mount resource rejects a missing folderId or a traversing mountName", () => { + assert.equal( + createSessionSchema.safeParse({ + bundleId: "tangle", + resources: [{ kind: "mount", folderId: "" }], + }).success, + false, + ); + for (const mountName of ["a/b", "..", ".", "a\\b"]) { + assert.equal( + createSessionSchema.safeParse({ + bundleId: "tangle", + resources: [{ kind: "mount", folderId: "f", mountName }], + }).success, + false, + `mountName "${mountName}" should be rejected`, + ); + } +}); + test("createSessionSchema accepts a string env map and rejects non-strings", () => { assert.equal( createSessionSchema.safeParse({ @@ -76,6 +116,7 @@ test("handleCreateSession returns 404 for unknown bundle ids", async () => { agentBundleStore, memory: {} as MemoryManager, resources: {} as ResourceCatalog, + sharedFolders: {} as SharedFolderStore, hostPreamble: {} as HostResourcePreamble, emitResourcesUpdated: () => {}, }; diff --git a/apps/server/src/routes/sessions/handlers.ts b/apps/server/src/routes/sessions/handlers.ts index 20f4fb82..3f1f9fd2 100644 --- a/apps/server/src/routes/sessions/handlers.ts +++ b/apps/server/src/routes/sessions/handlers.ts @@ -32,6 +32,7 @@ import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; import { readActivity } from "../../store/chatLog.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; +import type { SharedFolderStore } from "../../store/sharedFolderStore.ts"; import { injectPageBridge } from "./pageBridge.ts"; import type { CreateSessionInput, @@ -144,6 +145,7 @@ export interface SessionCreateDeps { agentBundleStore: AgentBundleStore; memory: MemoryManager; resources: ResourceCatalog; + sharedFolders: SharedFolderStore; hostPreamble: HostResourcePreamble; /** Notifies a session's room that its resource catalog changed. */ emitResourcesUpdated: (sessionId: string) => void; @@ -164,7 +166,12 @@ async function seedResources( if (resources.length === 0) return; for (const input of resources) { await applyResourceInput( - { store: deps.store, memory: deps.memory, catalog: deps.resources }, + { + store: deps.store, + memory: deps.memory, + catalog: deps.resources, + sharedFolders: deps.sharedFolders, + }, sessionId, rootPath, input, diff --git a/apps/server/src/routes/sessions/index.ts b/apps/server/src/routes/sessions/index.ts index 73d44561..1a5415b3 100644 --- a/apps/server/src/routes/sessions/index.ts +++ b/apps/server/src/routes/sessions/index.ts @@ -10,6 +10,7 @@ import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; import type { TriggerManager } from "../../pi/triggers/triggerManager.ts"; import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; +import type { SharedFolderStore } from "../../store/sharedFolderStore.ts"; import { type EnvironmentLister, registerEnvironmentRoutes, @@ -142,6 +143,7 @@ export function createSessionsRouter( agentBundleStore: AgentBundleStore, participants: ParticipantService, resources: ResourceCatalog, + sharedFolders: SharedFolderStore, memory: MemoryManager, hostPreamble: HostResourcePreamble, emitResourcesUpdated: (sessionId: string) => void, @@ -157,6 +159,7 @@ export function createSessionsRouter( agentBundleStore, memory, resources, + sharedFolders, hostPreamble, emitResourcesUpdated, }; @@ -171,6 +174,7 @@ export function createSessionsRouter( store, resources, memory, + sharedFolders, hostPreamble, emitResourcesUpdated, }); diff --git a/apps/server/src/routes/sessions/resources.test.ts b/apps/server/src/routes/sessions/resources.test.ts index 008716f6..2808ff19 100644 --- a/apps/server/src/routes/sessions/resources.test.ts +++ b/apps/server/src/routes/sessions/resources.test.ts @@ -27,6 +27,8 @@ const { HostResourcePreamble } = const { MemoryManager } = await import("../../pi/memory.ts"); const { InMemoryResourceStore } = await import("../../store/inMemoryResourceStore.ts"); +const { InMemorySharedFolderStore } = + await import("../../store/inMemorySharedFolderStore.ts"); const { InMemorySessionStore } = await import("../../store/inMemorySessionStore.ts"); const { InMemoryParticipantStore } = @@ -74,6 +76,7 @@ async function serve(admins?: ReadonlySet) { store: sessions, resources: catalog, memory: new MemoryManager(), + sharedFolders: new InMemorySharedFolderStore(), hostPreamble: new HostResourcePreamble(catalog), emitResourcesUpdated: () => {}, admins, diff --git a/apps/server/src/routes/sessions/resources.ts b/apps/server/src/routes/sessions/resources.ts index 4ea9d776..a8ed1e06 100644 --- a/apps/server/src/routes/sessions/resources.ts +++ b/apps/server/src/routes/sessions/resources.ts @@ -15,6 +15,7 @@ import { getValidated, validate } from "../../middleware/validate.ts"; import type { HostResourcePreamble } from "../../pi/hostResourcePreamble.ts"; import type { MemoryManager } from "../../pi/memory.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; +import type { SharedFolderStore } from "../../store/sharedFolderStore.ts"; import type { DeleteResourceQuery, HostResourceInputBody, @@ -34,6 +35,7 @@ export interface ResourceRouteDeps { store: SessionStore; resources: ResourceCatalog; memory: MemoryManager; + sharedFolders: SharedFolderStore; hostPreamble: HostResourcePreamble; /** Notifies a session's room that its resource catalog changed. */ emitResourcesUpdated: (sessionId: string) => void; @@ -43,7 +45,12 @@ export interface ResourceRouteDeps { /** The seed deps drawn from the route deps. */ function seedDeps(deps: ResourceRouteDeps): ResourceSeedDeps { - return { store: deps.store, memory: deps.memory, catalog: deps.resources }; + return { + store: deps.store, + memory: deps.memory, + catalog: deps.resources, + sharedFolders: deps.sharedFolders, + }; } /** diff --git a/apps/server/src/routes/sessions/schemas.ts b/apps/server/src/routes/sessions/schemas.ts index ee689db4..1d5664c6 100644 --- a/apps/server/src/routes/sessions/schemas.ts +++ b/apps/server/src/routes/sessions/schemas.ts @@ -60,6 +60,19 @@ export const hostResourceInputSchema = z.discriminatedUnion("kind", [ path: ["content"], }, ), + z.object({ + kind: z.literal("mount"), + folderId: z.string().trim().min(1), + mountName: z + .string() + .trim() + .min(1) + .refine( + (n) => !/[/\\]/.test(n) && n !== "." && n !== "..", + "mountName must be a single path segment", + ) + .optional(), + }), ]); export type HostResourceInputBody = z.infer; diff --git a/apps/server/src/routes/sharedFolders.ts b/apps/server/src/routes/sharedFolders.ts new file mode 100644 index 00000000..028a9e60 --- /dev/null +++ b/apps/server/src/routes/sharedFolders.ts @@ -0,0 +1,62 @@ +import type { + CreateSharedFolderResponse, + ListSharedFoldersResponse, +} from "@tangent/shared/contracts.ts"; +import { type Request, type Response, Router } from "express"; +import { z } from "zod"; + +import { resolveUserIdentity } from "../auth/identity.ts"; +import { getValidated, validate } from "../middleware/validate.ts"; +import type { SharedFolderStore } from "../store/sharedFolderStore.ts"; + +/** Body for `POST /api/shared-folders`. */ +const createSharedFolderSchema = z.object({ + name: z.string().optional(), +}); +type CreateSharedFolderInput = z.infer; + +/** Handles `GET /api/shared-folders`: lists every shared folder, oldest first. */ +async function handleList( + store: SharedFolderStore, + res: Response, +): Promise { + const response: ListSharedFoldersResponse = { + folders: await store.listSharedFolders(), + }; + res.json(response); +} + +/** Handles `POST /api/shared-folders`: creates a folder and its backing dir. */ +async function handleCreate( + store: SharedFolderStore, + req: Request, + body: CreateSharedFolderInput, + res: Response, +): Promise { + const user = + resolveUserIdentity(req.headers.cookie, req.headers.authorization) ?? + undefined; + const folder = await store.createSharedFolder({ name: body.name, user }); + const response: CreateSharedFolderResponse = { folder }; + res.status(201).json(response); +} + +export function createSharedFoldersRouter(store: SharedFolderStore): Router { + const router = Router(); + + router.get("/", (_req: Request, res: Response) => handleList(store, res)); + + router.post( + "/", + validate({ body: createSharedFolderSchema }), + (req: Request, res: Response) => + handleCreate( + store, + req, + getValidated(req).body, + res, + ), + ); + + return router; +} diff --git a/apps/server/src/store/db/migrations/0025_pretty_warpath.sql b/apps/server/src/store/db/migrations/0025_pretty_warpath.sql new file mode 100644 index 00000000..b921d8ec --- /dev/null +++ b/apps/server/src/store/db/migrations/0025_pretty_warpath.sql @@ -0,0 +1,6 @@ +CREATE TABLE `shared_folders` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `user_identity` text, + `created_at` text NOT NULL +); diff --git a/apps/server/src/store/db/migrations/meta/0025_snapshot.json b/apps/server/src/store/db/migrations/meta/0025_snapshot.json new file mode 100644 index 00000000..bd8ade88 --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0025_snapshot.json @@ -0,0 +1,1042 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3801445d-31c8-42c4-97f3-73df7f4a3630", + "prevId": "1326b23a-83f8-41cb-8a06-e657d2397e46", + "tables": { + "bundle_editors": { + "name": "bundle_editors", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_seq": { + "name": "next_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_session_idx": { + "name": "conversations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversations_session_agent_idx": { + "name": "conversations_session_agent_idx", + "columns": ["session_id", "agent_id"], + "isUnique": false + }, + "conversations_session_id": { + "name": "conversations_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_session_id_sessions_id_fk": { + "name": "conversations_session_id_sessions_id_fk", + "tableFrom": "conversations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "muted": { + "name": "muted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reaction'" + }, + "admission": { + "name": "admission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queue'" + }, + "transcript_visibility": { + "name": "transcript_visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memberships_session_conversation_idx": { + "name": "memberships_session_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "memberships_session_conversation_participant": { + "name": "memberships_session_conversation_participant", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": true + } + }, + "foreignKeys": { + "memberships_session_id_sessions_id_fk": { + "name": "memberships_session_id_sessions_id_fk", + "tableFrom": "memberships", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "participants": { + "name": "participants", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "presence": { + "name": "presence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connected'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_payload": { + "name": "agent_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "participants_session_idx": { + "name": "participants_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "participants_session_id": { + "name": "participants_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "participants_session_id_sessions_id_fk": { + "name": "participants_session_id_sessions_id_fk", + "tableFrom": "participants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reactor_state": { + "name": "reactor_state", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reactor_state_session_idx": { + "name": "reactor_state_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "reactor_state_session_participant_idx": { + "name": "reactor_state_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + }, + "reactor_state_session_participant_scope": { + "name": "reactor_state_session_participant_scope", + "columns": ["session_id", "participant_id", "scope_key"], + "isUnique": true + } + }, + "foreignKeys": { + "reactor_state_session_id_sessions_id_fk": { + "name": "reactor_state_session_id_sessions_id_fk", + "tableFrom": "reactor_state", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resource_grants": { + "name": "resource_grants", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resource_grants_conversation_participant_idx": { + "name": "resource_grants_conversation_participant_idx", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": false + }, + "resource_grants_conversation_participant_resource": { + "name": "resource_grants_conversation_participant_resource", + "columns": ["conversation_id", "participant_id", "resource_id"], + "isUnique": true + } + }, + "foreignKeys": { + "resource_grants_session_id_sessions_id_fk": { + "name": "resource_grants_session_id_sessions_id_fk", + "tableFrom": "resource_grants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_grants_resource_id_resources_id_fk": { + "name": "resource_grants_resource_id_resources_id_fk", + "tableFrom": "resource_grants", + "tableTo": "resources", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resource_references": { + "name": "resource_references", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resource_references_conversation_idx": { + "name": "resource_references_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "resource_references_conversation_resource": { + "name": "resource_references_conversation_resource", + "columns": ["conversation_id", "resource_id"], + "isUnique": true + } + }, + "foreignKeys": { + "resource_references_session_id_sessions_id_fk": { + "name": "resource_references_session_id_sessions_id_fk", + "tableFrom": "resource_references", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_references_resource_id_resources_id_fk": { + "name": "resource_references_resource_id_resources_id_fk", + "tableFrom": "resource_references", + "tableTo": "resources", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resources": { + "name": "resources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_participant_id": { + "name": "author_participant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resources_session_idx": { + "name": "resources_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "resources_session_uri": { + "name": "resources_session_uri", + "columns": ["session_id", "uri"], + "isUnique": true + } + }, + "foreignKeys": { + "resources_session_id_sessions_id_fk": { + "name": "resources_session_id_sessions_id_fk", + "tableFrom": "resources", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_assets": { + "name": "session_assets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_assets_session_idx": { + "name": "session_assets_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_assets_session_path": { + "name": "session_assets_session_path", + "columns": ["session_id", "path"], + "isUnique": true + } + }, + "foreignKeys": { + "session_assets_session_id_sessions_id_fk": { + "name": "session_assets_session_id_sessions_id_fk", + "tableFrom": "session_assets", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_views": { + "name": "session_views", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_key": { + "name": "user_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_views_session_idx": { + "name": "session_views_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_views_session_user": { + "name": "session_views_session_user", + "columns": ["session_id", "user_key"], + "isUnique": true + } + }, + "foreignKeys": { + "session_views_session_id_sessions_id_fk": { + "name": "session_views_session_id_sessions_id_fk", + "tableFrom": "session_views", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "root_path": { + "name": "root_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'created'" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_identity": { + "name": "user_identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "access_policy": { + "name": "access_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_folders": { + "name": "shared_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_identity": { + "name": "user_identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/server/src/store/db/migrations/meta/_journal.json b/apps/server/src/store/db/migrations/meta/_journal.json index ffb156b9..fc103e96 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -176,6 +176,13 @@ "when": 1793379853637, "tag": "0024_bizarre_mongu", "breakpoints": true + }, + { + "idx": 25, + "version": "6", + "when": 1793421867727, + "tag": "0025_pretty_warpath", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index b73fb183..e74bd89f 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -43,6 +43,21 @@ export const sessions = sqliteTable("sessions", { updatedAt: text("updated_at").notNull(), }); +/** + * A server-managed shared folder: a cross-session "project" directory that any + * number of sessions mount (by symlink) into their root. Deliberately NOT + * session-scoped — its lifecycle is independent of any one session, so there is + * no FK to `sessions`. The bytes live under `SHARED_ROOT/`; a session's + * mount is recorded as a `kind: "mount"` row in {@link resources}. + */ +export const sharedFolders = sqliteTable("shared_folders", { + id: text("id").primaryKey(), + name: text("name").notNull(), + /** Serialized `UserIdentity` (the human who created it), if resolved. */ + userIdentity: text("user_identity"), + createdAt: text("created_at").notNull(), +}); + /** * An asset (pinned artifact) a user or agent pinned for quick access, scoped to * a session. The autoincrement `id` preserves oldest-first ordering; re-pinning @@ -271,7 +286,7 @@ export const resources = sqliteTable( sessionId: text("session_id") .notNull() .references(() => sessions.id, { onDelete: "cascade" }), - /** `file` | `memory` | `attachment` | `artifact`. */ + /** `file` | `memory` | `attachment` | `artifact` | `host` | `mount`. */ kind: text("kind").notNull(), /** Display name / title. */ name: text("name").notNull(), @@ -442,6 +457,7 @@ export const bundleEditors = sqliteTable("bundle_editors", { }); export type SessionRow = typeof sessions.$inferSelect; +export type SharedFolderRow = typeof sharedFolders.$inferSelect; export type SessionAssetRow = typeof sessionAssets.$inferSelect; export type RunRow = typeof runs.$inferSelect; export type ConversationRow = typeof conversations.$inferSelect; diff --git a/apps/server/src/store/inMemorySharedFolderStore.ts b/apps/server/src/store/inMemorySharedFolderStore.ts new file mode 100644 index 00000000..4677bd51 --- /dev/null +++ b/apps/server/src/store/inMemorySharedFolderStore.ts @@ -0,0 +1,43 @@ +import { randomUUID } from "node:crypto"; +import { mkdir } from "node:fs/promises"; + +import type { SharedFolder } from "@tangent/shared/contracts.ts"; + +import { + type CreateSharedFolderParams, + sharedFolderPath, + type SharedFolderStore, +} from "./sharedFolderStore.ts"; + +/** + * Process-local {@link SharedFolderStore} for tests, mirroring + * {@link import("./inMemoryResourceStore.ts").InMemoryResourceStore}. Still + * creates each folder's backing directory on disk (under `SHARED_ROOT`) so a + * mount's symlink has a real target. + */ +export class InMemorySharedFolderStore implements SharedFolderStore { + private readonly byId = new Map(); + + async listSharedFolders(): Promise { + return [...this.byId.values()]; + } + + async getSharedFolder(id: string): Promise { + return this.byId.get(id); + } + + async createSharedFolder( + input: CreateSharedFolderParams, + ): Promise { + const id = randomUUID(); + await mkdir(sharedFolderPath(id), { recursive: true }); + const folder: SharedFolder = { + id, + name: input.name?.trim() || `Shared folder ${this.byId.size + 1}`, + user: input.user, + createdAt: new Date().toISOString(), + }; + this.byId.set(id, folder); + return folder; + } +} diff --git a/apps/server/src/store/sharedFolderStore.ts b/apps/server/src/store/sharedFolderStore.ts new file mode 100644 index 00000000..91478338 --- /dev/null +++ b/apps/server/src/store/sharedFolderStore.ts @@ -0,0 +1,34 @@ +import path from "node:path"; + +import type { SharedFolder, UserIdentity } from "@tangent/shared/contracts.ts"; + +import { SHARED_ROOT } from "../config.ts"; + +/** Absolute path to a shared folder's backing directory under `SHARED_ROOT`. */ +export function sharedFolderPath(id: string): string { + return path.join(SHARED_ROOT, id); +} + +/** + * Input accepted by {@link SharedFolderStore.createSharedFolder}: the public + * wire request plus the server-resolved {@link UserIdentity} (from the creator's + * Oktasso JWT), which is never part of the client-supplied body. + */ +export interface CreateSharedFolderParams { + name?: string; + user?: UserIdentity; +} + +/** + * Storage abstraction for shared folders — the cross-session "project" folders a + * session mounts into its root by symlink. Their bytes live under `SHARED_ROOT`; + * this table holds only their metadata. Kept apart from + * {@link import("./sessionStore.ts").SessionStore} because a shared folder + * outlives any one session and is not session-scoped. + */ +export interface SharedFolderStore { + listSharedFolders(): Promise; + getSharedFolder(id: string): Promise; + /** Creates the folder's metadata row and its backing directory on disk. */ + createSharedFolder(input: CreateSharedFolderParams): Promise; +} diff --git a/apps/server/src/store/sqliteSharedFolderStore.test.ts b/apps/server/src/store/sqliteSharedFolderStore.test.ts new file mode 100644 index 00000000..4c82f557 --- /dev/null +++ b/apps/server/src/store/sqliteSharedFolderStore.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +// Point the session + shared roots at throwaway dirs before importing modules +// that read config at load time, so the mkdirs never touch the repo. +const ROOT = mkdtempSync(path.join(tmpdir(), "shared-store-sess-")); +const SHARED = mkdtempSync(path.join(tmpdir(), "shared-store-")); +process.env.SESSIONS_ROOT = ROOT; +process.env.SHARED_ROOT = SHARED; + +const { openDb } = await import("./db/client.ts"); +const { SqliteSharedFolderStore } = + await import("./sqliteSharedFolderStore.ts"); +const { sharedFolderPath } = await import("./sharedFolderStore.ts"); + +after(() => { + rmSync(ROOT, { recursive: true, force: true }); + rmSync(SHARED, { recursive: true, force: true }); +}); + +function fixture() { + return new SqliteSharedFolderStore(openDb(":memory:")); +} + +test("createSharedFolder persists a row and makes its backing directory", async () => { + const store = fixture(); + + const folder = await store.createSharedFolder({ name: "Project X" }); + + assert.equal(folder.name, "Project X"); + assert.match(folder.id, /[0-9a-f-]{36}/); + assert.equal(existsSync(sharedFolderPath(folder.id)), true); + + const fetched = await store.getSharedFolder(folder.id); + assert.deepEqual(fetched, folder); +}); + +test("a blank name falls back to a generated label", async () => { + const store = fixture(); + + const first = await store.createSharedFolder({}); + const second = await store.createSharedFolder({ name: " " }); + + assert.equal(first.name, "Shared folder 1"); + assert.equal(second.name, "Shared folder 2"); +}); + +test("listSharedFolders returns every created folder", async () => { + const store = fixture(); + const a = await store.createSharedFolder({ name: "A" }); + const b = await store.createSharedFolder({ name: "B" }); + + const ids = (await store.listSharedFolders()).map((folder) => folder.id); + assert.equal(ids.length, 2); + assert.deepEqual(new Set(ids), new Set([a.id, b.id])); +}); + +test("getSharedFolder returns undefined for an unknown id", async () => { + const store = fixture(); + assert.equal(await store.getSharedFolder("nope"), undefined); +}); diff --git a/apps/server/src/store/sqliteSharedFolderStore.ts b/apps/server/src/store/sqliteSharedFolderStore.ts new file mode 100644 index 00000000..4e8e962a --- /dev/null +++ b/apps/server/src/store/sqliteSharedFolderStore.ts @@ -0,0 +1,87 @@ +import { randomUUID } from "node:crypto"; +import { mkdir } from "node:fs/promises"; + +import type { SharedFolder, UserIdentity } from "@tangent/shared/contracts.ts"; +import { asc, count, eq } from "drizzle-orm"; + +import type { Db } from "./db/client.ts"; +import { type SharedFolderRow, sharedFolders } from "./db/schema.ts"; +import { + type CreateSharedFolderParams, + sharedFolderPath, + type SharedFolderStore, +} from "./sharedFolderStore.ts"; + +/** Maps a shared_folders row onto the wire {@link SharedFolder}. */ +function toSharedFolder(row: SharedFolderRow): SharedFolder { + return { + id: row.id, + name: row.name, + user: row.userIdentity + ? (JSON.parse(row.userIdentity) as UserIdentity) + : undefined, + createdAt: row.createdAt, + }; +} + +/** SQLite-backed {@link SharedFolderStore} over the shared session metadata DB. */ +export class SqliteSharedFolderStore implements SharedFolderStore { + private readonly db: Db; + + constructor(db: Db) { + this.db = db; + } + + async listSharedFolders(): Promise { + const rows = this.db + .select() + .from(sharedFolders) + .orderBy(asc(sharedFolders.createdAt)) + .all(); + return rows.map(toSharedFolder); + } + + async getSharedFolder(id: string): Promise { + const row = this.db + .select() + .from(sharedFolders) + .where(eq(sharedFolders.id, id)) + .get(); + return row ? toSharedFolder(row) : undefined; + } + + async createSharedFolder( + input: CreateSharedFolderParams, + ): Promise { + const id = randomUUID(); + const now = new Date().toISOString(); + + // The backing directory other sessions symlink to. Created up front so a + // mount never races a directory that does not exist yet. + await mkdir(sharedFolderPath(id), { recursive: true }); + + const [{ value: existing }] = this.db + .select({ value: count() }) + .from(sharedFolders) + .all(); + + const folder: SharedFolder = { + id, + name: input.name?.trim() || `Shared folder ${existing + 1}`, + user: input.user, + createdAt: now, + }; + + this.db + .insert(sharedFolders) + .values({ + id: folder.id, + name: folder.name, + userIdentity: folder.user ? JSON.stringify(folder.user) : null, + createdAt: folder.createdAt, + }) + .run(); + + return folder; + } +} diff --git a/apps/web/src/features/chat/model/resources.ts b/apps/web/src/features/chat/model/resources.ts index 91655149..02cdc6b0 100644 --- a/apps/web/src/features/chat/model/resources.ts +++ b/apps/web/src/features/chat/model/resources.ts @@ -8,6 +8,7 @@ export const RESOURCE_ICON: Record = { attachment: "Paperclip", memory: "Brain", host: "Link", + mount: "FolderSymlink", }; /** Human-readable label for a resource's kind, shown as its subtitle. */ @@ -23,6 +24,8 @@ export function resourceKindLabel(kind: ResourceKind): string { return "Memory"; case "host": return "Host"; + case "mount": + return "Shared folder"; } } diff --git a/docs/server/index.md b/docs/server/index.md index a2193592..2af8a1a7 100644 --- a/docs/server/index.md +++ b/docs/server/index.md @@ -119,6 +119,9 @@ SqliteResourceStore(db)`, `membershipStore = new SqliteMembershipStore(db)` — the relational stores for the roster, catalog, and memberships. - `store = new SqliteSessionStore(db, participants, resourceStore)` — the durable session store backing REST routes and socket handlers. +- `sharedFolderStore = new SqliteSharedFolderStore(db)` — the registry of + cross-session shared folders a session mounts (by symlink) into its root; bytes + live under `SHARED_ROOT`. - `agentBundleStore = new FileAgentBundleStore()` — the filesystem-backed marketplace of saved bundles. - `memory = new MemoryManager()` — the global + per-session memory files. @@ -158,8 +161,9 @@ participantService)` — arms schedule timers and posts firings into the target' The Express app mounts: - `GET /api/health` -- `/api/sessions` -> `createSessionsRouter(store, pi, triggers, triggerEngine, agentBundleStore, participantService, resourceCatalog, memory, hostResourcePreamble, emitResourcesUpdated)` +- `/api/sessions` -> `createSessionsRouter(store, pi, triggers, triggerEngine, agentBundleStore, participantService, resourceCatalog, sharedFolderStore, memory, hostResourcePreamble, emitResourcesUpdated, …)` - `/api/agent-bundles` -> `createAgentBundlesRouter(agentBundleStore)` +- `/api/shared-folders` -> `createSharedFoldersRouter(sharedFolderStore)` (cross-session folders a session mounts into its root) - `/api/global-memory` -> `createGlobalMemoryRouter(memory)` - `/api/mcp` -> `createMcpRelayRouter(mcpRelay, relayReport)` (public MCP relay dialed by an external client) - `/api/me` -> `createMeRouter()` (resolves the current user from an auth JWT cookie) @@ -243,6 +247,8 @@ From [apps/server/src/config.ts](../../apps/server/src/config.ts): - `SESSIONS_ROOT` (default `/.sessions`) — each session gets `/`. - `SESSIONS_DB` (default `/tangent.db`) — the SQLite metadata DB. - `AGENT_BUNDLES_ROOT` (default `/.agent-bundles`) — marketplace storage. +- `SHARED_ROOT` (default `/.shared`) — each shared folder gets `/`; + sessions symlink these in as mounts. - `GLOBAL_MEMORY_DIR` (default `/.memory`) + `GLOBAL_MEMORY_FILENAME` (`GLOBAL_MEMORY.md`) and `SESSION_MEMORY_FILENAME` (`MEMORY.md`). - `ARTIFACTS_DIRNAME` (`artifacts`) and `UPLOADS_DIRNAME` (`uploads`) — the only @@ -276,7 +282,10 @@ From [apps/server/src/config.ts](../../apps/server/src/config.ts): - **Run** — one unit of work by one participant: what a stream of `agent:*` events is attributable to, and what cancellation acts on. - **Resource** — session-owned catalogued content: `file`, `memory`, `attachment`, - or `artifact`. + `artifact`, `host`, or `mount`. +- **Shared folder** — a server-managed, cross-session "project" directory under + `SHARED_ROOT`; a session gains access by mounting it (a `mount` resource whose + symlink points at it from the session root). One-way: mounts are not undone. - **Room** — a Socket.IO room. `conv::` carries a Conversation's Messages; `session:` carries session-level events. - **Trigger** — a per-session rule that turns an external signal into a prompt diff --git a/docs/server/sessions-and-storage.md b/docs/server/sessions-and-storage.md index fdd1fa24..a73a9ace 100644 --- a/docs/server/sessions-and-storage.md +++ b/docs/server/sessions-and-storage.md @@ -5,8 +5,8 @@ A **session** is the unit of work: its Conversations, a scoped workspace folder on disk, its Participants and connectors, plus its triggers, memory, and Resources. This doc covers the session model, the `SessionStore` abstraction, the -SQLite schema, the per-session root folder layout, and the path-traversal-guarded -file server for artifacts and uploads. +SQLite schema, the per-session root folder layout, cross-session shared folders +(mounts), and the path-traversal-guarded file server for artifacts and uploads. --- @@ -64,9 +64,10 @@ changes go exclusively through drizzle migrations; never alter tables ad-hoc. | `conversations` | per-Conversation `seq` counter and its owning agent; maps a Conversation id to its transcript. | | `memberships` | a `(participant, conversation)` attachment: reaction spec, ingress, transcript visibility. | | `runs` | one unit of work by one participant: status, ingress, home conversation, external id, cursor. | -| `resources` | the catalog: `file` / `memory` / `attachment` / `artifact`, pointing at bytes by `uri`. | +| `resources` | the catalog: `file` / `memory` / `attachment` / `artifact` / `host` / `mount`, pointing at bytes by `uri`. | | `resource_references` | a resource surfaced into a Conversation (surfacing + citation, not a filesystem gate). | | `resource_grants` | per-Membership refinement of a reference; default-permissive (an empty table changes nothing). | +| `shared_folders` | cross-session "project" folders a session mounts (by symlink) into its root. Not session-scoped — no FK to `sessions`; bytes live under `SHARED_ROOT/`. | | `session_views` | when each user last opened a session. | `participants` is the roster's only store. The old `session_agents` table (and @@ -86,6 +87,7 @@ activity it looks like: .sessions// artifacts/ # user-facing files agents write (served over HTTP) uploads/ # human chat attachments (served over HTTP) + / # symlink -> SHARED_ROOT/ (mounted shared folder) MEMORY.md # session memory (created on Prime's first remember) GLOBAL_MEMORY.md # read-only snapshot of global memory AGENTS.md # bundle rules file (Pi auto-discovers from cwd) @@ -107,6 +109,71 @@ from `cwd`. --- +## Shared folders (mounts) + +A **shared folder** is a server-managed directory that lives _outside_ any one +session's root, under `SHARED_ROOT` (default `/.shared`, one subdirectory +per folder: `SHARED_ROOT/`). Several sessions can **mount** the same +shared folder, giving them a common workspace — a lightweight "project" — whose +edits are live across every session that mounts it. + +Because an agent runs with `cwd` set to its session root and its tools follow +symlinks, a mount is just a symlink at the session root pointing at the shared +folder: + +``` +SESSIONS_ROOT// -> SHARED_ROOT/ +``` + +The agent reads, greps, edits, and runs shell commands against `/` +with its ordinary file tools, staying "inside" its root by convention while the +bytes it touches are shared. + +**Registry.** Folder metadata (`id`, `name`, optional creator `user_identity`, +`created_at`) lives in the `shared_folders` table, written through +[`SqliteSharedFolderStore`](../../apps/server/src/store/sqliteSharedFolderStore.ts) +(interface + `sharedFolderPath` in +[sharedFolderStore.ts](../../apps/server/src/store/sharedFolderStore.ts)). The +folder is **not** session-scoped — it outlives any session and has no FK to +`sessions`. The registry REST surface is: + +- `POST /api/shared-folders` — create a folder (and its backing directory). Body + `{ name? }`; returns `{ folder }`. +- `GET /api/shared-folders` — list every folder, oldest-first. + +**Mounting** reuses the resource-seeding pipeline rather than a bespoke endpoint: +a mount is a `HostResourceInput` of `kind: "mount"`, so it can be passed at +session create (`POST /api/sessions` `resources: [...]`) or added later +(`POST /api/sessions/:id/resources`). `applyResourceInput` +([conversation/hostResources.ts](../../apps/server/src/conversation/hostResources.ts)) +resolves the folder in the registry (an unknown `folderId` is a 400 — **raw host +paths are never accepted**), picks a `mountName` (defaulting to a sanitized form +of the folder name), rejects a reserved name (`artifacts`, `uploads`, `.tangent`, +`AGENTS.md`, `MEMORY.md`, `GLOBAL_MEMORY.md`) or a collision with an existing +entry, creates the symlink, and catalogs a `mount` resource +(`uri: mount://`, `meta: { sharedFolderId, mountName, target }`). + +**One-way.** There is no unmount endpoint. Removing the `mount` resource drops +the catalog row but **leaves the symlink in place**, so the agent keeps its +access. Re-applying the same mount is idempotent (it reuses the stored +`mountName` and the existing link). + +**Limitations / notes.** + +- Mounted trees are **not** served over the file HTTP API (only `artifacts/` and + `uploads/` are) and are **not** recursively catalogued by the workspace scan + (`catalogWorkspaceFiles`), which only walks those two subtrees — avoiding huge + scans. +- There is **no cross-session write locking**: two sessions editing the same + files can race. The folder is genuinely shared, not copied. +- Confinement is by convention — a mount is a deliberate hole in it, and `bash` + can traverse out through the link. +- `deleteSession` does not remove the on-disk folder today; if recursive cleanup + is ever added it must remove the _symlink_ (an `fs.rm` on a link deletes the + link, not the shared folder's contents). + +--- + ## Uploads `POST /api/sessions/:id/files` ([routes/sessions.ts](../../server/src/routes/sessions.ts)) diff --git a/docs/server/ui-server-protocol.md b/docs/server/ui-server-protocol.md index 7b4d5490..c9a8b76c 100644 --- a/docs/server/ui-server-protocol.md +++ b/docs/server/ui-server-protocol.md @@ -50,13 +50,14 @@ implemented under [server/src/routes/](../../server/src/routes). | `DELETE /api/sessions/:id/participants/:participantId/memberships/:conversationId` | Leave a Conversation. | | `PATCH /api/sessions/:id/participants/:participantId/memberships/:conversationId` | Mute/unmute a membership (mute = reaction `never`). | | `GET /api/sessions/:id/resources` | List the resource catalog (optionally scoped to `?conversationId=&participantId=`). | -| `POST /api/sessions/:id/resources` | Catalog a host resource (a workspace file, etc.). | -| `DELETE /api/sessions/:id/resources` | Remove a catalog entry. | +| `POST /api/sessions/:id/resources` | Catalog a host resource (a workspace file, a memory seed, a shared-folder `mount`, etc.). | +| `DELETE /api/sessions/:id/resources` | Remove a catalog entry (a `mount` is one-way: the row drops but its symlink stays). | | `GET /api/sessions/:id/files/*splat` | Serve a file from the session's `artifacts/` or `uploads/` subtree (path-traversal guarded). | -Other API mounts: `/api/agent-bundles`, `/api/global-memory`, `/api/mcp` (public -MCP relay), `/api/me` (current user), `/api/embed`, and the token-guarded -`/internal/*` routers the agent extensions call. +Other API mounts: `/api/agent-bundles`, `/api/shared-folders`, +`/api/global-memory`, `/api/mcp` (public MCP relay), `/api/me` (current user), +`/api/embed`, and the token-guarded `/internal/*` routers the agent extensions +call. ### Agent bundles — [routes/agentBundles.ts](../../server/src/routes/agentBundles.ts) @@ -71,6 +72,17 @@ MCP relay), `/api/me` (current user), `/api/embed`, and the token-guarded | `POST /api/agent-bundles/ui-egress` | The bundle-UI `host.fetch` egress proxy. | | `DELETE /api/agent-bundles/:id` | Delete a saved bundle. | +### Shared folders — [routes/sharedFolders.ts](../../apps/server/src/routes/sharedFolders.ts) + +Cross-session "project" folders a session mounts (by symlink) into its root. A +folder is created once here, then mounted into any session as a `mount` resource +(`POST /api/sessions/:id/resources` or at create via `resources: [...]`). + +| Method + path | Purpose | +| -------------------------- | --------------------------------------------------------------- | +| `GET /api/shared-folders` | List every shared folder, oldest-first. | +| `POST /api/shared-folders` | Create a shared folder (and its backing dir). Body `{ name? }`. | + `GET /api/health` returns `{ ok: true }`. --- diff --git a/packages/embed-react/README.md b/packages/embed-react/README.md index b9c422f5..5442a147 100644 --- a/packages/embed-react/README.md +++ b/packages/embed-react/README.md @@ -139,9 +139,10 @@ const { sessionId } = await newSession( #### Resources A session holds a catalog of resources — memory documents, host-provided -entries, attachments, and workspace files. The host can seed, add, list, and -remove the three kinds it owns (`memory`, `host`, and `file`); artifacts and -attachments stay on their own mechanisms and are read-only through this API. +entries, workspace files, mounted shared folders, and attachments. The host can +seed, add, list, and remove the four kinds it owns (`memory`, `host`, `file`, and +`mount`); artifacts and attachments stay on their own mechanisms and are +read-only through this API. ```ts type HostResourceInput = @@ -154,7 +155,8 @@ type HostResourceInput = encoding?: "utf8" | "base64"; name?: string; meta?: Record; - }; + } + | { kind: "mount"; folderId: string; mountName?: string }; listResources(sessionId: string): Promise; addResource(sessionId: string, input: HostResourceInput): Promise; @@ -175,6 +177,16 @@ agent to know it exists. Removing a `memory` resource clears that store; removin a `host` resource drops the catalog entry, and removing a `file` drops the entry and deletes its bytes from disk. +A `mount` entry symlinks a **shared folder** — a server-managed, cross-session +"project" directory — into the session root, so the agent reads and writes it with +its ordinary file tools (and several sessions mounting the same folder share one +live copy). Create a shared folder first via the server's registry API +(`POST /api/shared-folders` → `{ folder }`) and mount it by its `folderId`; +`mountName` is the single-segment symlink name and defaults to a sanitized form of +the folder's name (reserved names like `artifacts`, `uploads`, and `.tangent` are +rejected). Mounting is **one-way**: removing a `mount` resource drops the catalog +entry but leaves the symlink in place, so the agent keeps its access. + ```tsx const { newSession, addResource } = useTangent(); @@ -206,6 +218,20 @@ await addResource(sessionId, { uri: "https://tangent.example/pipelines/returns", meta: { url: "...", name: "Returns pipeline", description: "..." }, }); + +// Mount a shared "project" folder so the agent can work in it directly. +// Create it once via the server registry, then mount it into any session: +const { folder } = await fetch("/api/shared-folders", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Orders project" }), +}).then((r) => r.json()); + +await addResource(sessionId, { + kind: "mount", + folderId: folder.id, + mountName: "project", +}); ``` ### `` diff --git a/packages/embed-react/src/types.ts b/packages/embed-react/src/types.ts index 3fc8070b..a30131be 100644 --- a/packages/embed-react/src/types.ts +++ b/packages/embed-react/src/types.ts @@ -104,7 +104,8 @@ export type EmbedResourceKind = | "memory" | "attachment" | "artifact" - | "host"; + | "host" + | "mount"; /** * A catalogued piece of content in a session, as returned to the host. The @@ -131,6 +132,8 @@ export interface EmbedResource { * so it can never overwrite a system file) so bundle tools can read it on disk — * text passes as-is, binary rides in `content` as base64. Removing a `file` * deletes those bytes; unlike the other kinds it is not announced to the agent. + * A `mount` entry symlinks an existing shared folder into the session root so the + * agent reads and writes it with its ordinary file tools; mounting is one-way. */ export type HostResourceInput = | { kind: "memory"; scope?: "session" | "global"; content: string } @@ -151,6 +154,13 @@ export type HostResourceInput = /** Catalog display name; defaults to the path basename. */ name?: string; meta?: Record; + } + | { + kind: "mount"; + /** Id of the shared folder to symlink into the session root. */ + folderId: string; + /** Single-segment symlink name; defaults to the folder's sanitized name. */ + mountName?: string; }; /** Host-driven theme inputs forwarded to ``. */ diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 844ae574..e0155d54 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -470,7 +470,8 @@ export type ResourceKind = | "memory" | "attachment" | "artifact" - | "host"; + | "host" + | "mount"; /** * A catalogued piece of content in a session — a pinned `artifact`, a human @@ -511,7 +512,9 @@ export interface ListResourcesResponse { * `uploads/` so it can never overwrite a system file) so bundle tools can read * it on disk — text passes as-is, binary rides in `content` as base64. Removing * a `file` deletes those bytes; unlike the other kinds it is not announced to - * the agent. + * the agent. A `mount` entry symlinks an existing {@link SharedFolder} into the + * session root so the agent reads and writes it with its ordinary file tools; + * mounting is one-way (removing the catalog entry leaves the symlink in place). */ export type HostResourceInput = | { kind: "memory"; scope?: MemoryScope; content: string } @@ -532,8 +535,49 @@ export type HostResourceInput = /** Catalog display name; defaults to the path basename. */ name?: string; meta?: Record; + } + | { + kind: "mount"; + /** Id of the {@link SharedFolder} to symlink into the session root. */ + folderId: string; + /** + * Single-segment name for the symlink under the session root. Defaults to + * a sanitized form of the shared folder's name. + */ + mountName?: string; }; +/** + * A server-managed shared folder: a cross-session "project" directory that any + * number of sessions can mount (by symlink) into their root. Its bytes live + * under `SHARED_ROOT/` and are shared live across every session that mounts + * it. + */ +export interface SharedFolder { + id: string; + name: string; + /** The human who created it, resolved from their Oktasso JWT, when known. */ + user?: UserIdentity; + /** ISO-8601 timestamp. */ + createdAt: string; +} + +/** Body of `POST /api/shared-folders`. */ +export interface CreateSharedFolderRequest { + /** Display name; defaults to a generated `Shared folder N` when omitted. */ + name?: string; +} + +/** Response from `POST /api/shared-folders`: the created folder. */ +export interface CreateSharedFolderResponse { + folder: SharedFolder; +} + +/** Response from `GET /api/shared-folders`. */ +export interface ListSharedFoldersResponse { + folders: SharedFolder[]; +} + /** Response from `POST /api/sessions/:id/resources`: the stored resource. */ export interface AddResourceResponse { resource: Resource;