Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`) 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");

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.


/**
* Name of the per-session subdirectory whose contents are served over HTTP as
* artifacts (images, generated HTML pages, reports). Agents write user-facing
Expand Down
129 changes: 127 additions & 2 deletions apps/server/src/conversation/hostResources.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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");
Expand All @@ -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";
Expand All @@ -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() {
Expand Down Expand Up @@ -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);
});
Loading
Loading