Skip to content
Draft
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
14 changes: 14 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ if (nested) {
);
}

/**
* Root directory backing the global extension registry: Pi packages
* (extensions, skills, prompt templates) installed from a marketplace (npm,
* git, file upload) and applied to sessions that opt in. Each install gets its
* own subdirectory (`EXTENSIONS_ROOT/<id>`) holding its `extension.json`
* metadata, the preserved upload (`source/`), and the installed tree (`pkg/`).
*
* Defaults under {@link SESSIONS_ROOT} so the same persistent volume that holds
* sessions also survives a pod restart with no extra deployment wiring; the
* server reconciles (reinstalling anything missing) on every start.
*/
export const EXTENSIONS_ROOT =
process.env.EXTENSIONS_ROOT ?? path.join(SESSIONS_ROOT, ".extensions");

/**
* 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
136 changes: 136 additions & 0 deletions apps/server/src/extensions/fileExtensionStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
mkdir,
mkdtemp,
readdir,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import path from "node:path";

import type { GlobalExtensionMeta } from "@tangent/shared/contracts.ts";

import { EXTENSIONS_ROOT } from "../config.ts";

/** Metadata filename stored at the root of each install's `<root>/<id>` dir. */
const META_FILENAME = "extension.json";

/** Install subdirectory holding the installed package tree. */
export const PKG_DIRNAME = "pkg";

/** Install subdirectory preserving a `file` source's original upload. */
export const SOURCE_DIRNAME = "source";

/** Rejects ids that aren't a single, traversal-free path segment. */
export function isUnsafeId(id: string): boolean {
return (
id.length === 0 ||
id.includes("/") ||
id.includes("\\") ||
id.includes("..")
);
}

/**
* Filesystem-backed persistence for installed global extensions, mirroring
* {@link import("../store/fileAgentBundleStore.ts").FileAgentBundleStore}. Each
* install lives under `EXTENSIONS_ROOT/<id>/` as `extension.json`, a `pkg/`
* tree, and (for `file` sources) a preserved `source/` upload. Writes go to a
* sibling staging directory and are swapped in with an atomic rename so a failed
* install never leaves a half-written entry.
*/
export class FileExtensionStore {
private readonly root: string;

constructor(root: string = EXTENSIONS_ROOT) {
this.root = root;
}

/** Absolute path to an install's storage directory. */
dir(id: string): string {
return path.join(this.root, id);
}

/** Absolute path to an install's package tree. */
pkgDir(id: string): string {
return path.join(this.dir(id), PKG_DIRNAME);
}

/** Absolute path to an install's preserved `file` upload directory. */
sourceDir(id: string): string {
return path.join(this.dir(id), SOURCE_DIRNAME);
}

async get(id: string): Promise<GlobalExtensionMeta | undefined> {
if (isUnsafeId(id)) return undefined;
try {
const raw = await readFile(
path.join(this.dir(id), META_FILENAME),
"utf8",
);
return JSON.parse(raw) as GlobalExtensionMeta;
} catch {
return undefined;
}
}

async list(): Promise<GlobalExtensionMeta[]> {
let ids: string[];
try {
ids = await readdir(this.root);
} catch {
return [];
}
const metas: GlobalExtensionMeta[] = [];
for (const id of ids) {
if (id.startsWith(".")) continue;
const meta = await this.get(id);
if (meta) metas.push(meta);
}
return metas.sort((a, b) => a.id.localeCompare(b.id));
}

async delete(id: string): Promise<boolean> {
if (isUnsafeId(id)) return false;
if (!(await this.get(id))) return false;
await rm(this.dir(id), { recursive: true, force: true });
return true;
}

/** Creates an empty staging directory (same volume) to install into. */
async makeStaging(): Promise<string> {
await mkdir(this.root, { recursive: true });
return mkdtemp(path.join(this.root, ".staging-"));
}

/** Writes an install's `extension.json` into a directory (staging or final). */
async writeMeta(dir: string, meta: GlobalExtensionMeta): Promise<void> {
await writeFile(
path.join(dir, META_FILENAME),
JSON.stringify(meta, null, 2),
);
}

/** Atomically swaps a staged install into `<root>/<id>`, replacing any prior. */
async commit(staging: string, id: string): Promise<void> {
const dest = this.dir(id);
await rm(dest, { recursive: true, force: true });
await rename(staging, dest);
}

/** Discards a staging directory (on a failed install). */
async discard(staging: string): Promise<void> {
await rm(staging, { recursive: true, force: true });
}

/** The first preserved upload under an install's `source/`, if any. */
async sourceFile(id: string): Promise<string | undefined> {
try {
const files = await readdir(this.sourceDir(id));
return files[0] ? path.join(this.sourceDir(id), files[0]) : undefined;
} catch {
return undefined;
}
}
}
198 changes: 198 additions & 0 deletions apps/server/src/extensions/globalExtensionService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { after, test } from "node:test";

import { FileExtensionStore } from "./fileExtensionStore.ts";
import {
ExtensionConflictError,
GlobalExtensionService,
} from "./globalExtensionService.ts";
import { MarketplaceRegistry } from "./marketplaces/registry.ts";
import type { ExtensionMarketplace } from "./types.ts";

const ROOT = mkdtempSync(path.join(tmpdir(), "ext-service-"));
after(() => rmSync(ROOT, { recursive: true, force: true }));

/** A marketplace that writes a minimal pi package, counting its installs. */
class FakeMarketplace implements ExtensionMarketplace {
readonly id = "fake";
installs = 0;
fail = false;

async install(ref: string, pkgDir: string): Promise<string> {
this.installs += 1;
if (this.fail) throw new Error("install boom");
await mkdir(pkgDir, { recursive: true });
await writeFile(
path.join(pkgDir, "package.json"),
JSON.stringify({
name: ref,
version: "1.0.0",
pi: { extensions: ["index.ts"] },
tangent: { tools: ["fake_tool"] },
}),
);
await writeFile(path.join(pkgDir, "index.ts"), "// fake extension");
return pkgDir;
}
}

/** Builds a service over a fresh temp store with one fake marketplace. */
function makeService(name: string): {
service: GlobalExtensionService;
store: FileExtensionStore;
fake: FakeMarketplace;
} {
const store = new FileExtensionStore(path.join(ROOT, name));
const registry = new MarketplaceRegistry();
const fake = new FakeMarketplace();
registry.register(fake);
return { service: new GlobalExtensionService(registry, store), store, fake };
}

test("installs from a source, recording resources, tools, and status", async () => {
const { service, store } = makeService("install");

const meta = await service.installFromSource("fake:my-ext");

assert.equal(meta.id, "my-ext");
assert.equal(meta.status, "ready");
assert.deepEqual(meta.tools, ["fake_tool"]);
assert.deepEqual(meta.resources.extensions, ["index.ts"]);
assert.ok(existsSync(path.join(store.pkgDir("my-ext"), "index.ts")));
assert.deepEqual(service.readyIds(), ["my-ext"]);
});

/** A real extension (no declared tools) that registers two tools on load. */
const PROBE_EXTENSION = `export default function (pi) {
for (const name of ["alpha_tool", "beta_tool"]) {
pi.registerTool({
name,
label: name,
description: name,
parameters: { type: "object", properties: {} },
execute: async () => ({ content: [], details: {} }),
});
}
}
`;

/** A marketplace whose package declares no tools; `skillsOnly` ships no code. */
class UndeclaredMarketplace implements ExtensionMarketplace {
readonly id = "undeclared";
readonly skillsOnly: boolean;
constructor(skillsOnly = false) {
this.skillsOnly = skillsOnly;
}

async install(ref: string, pkgDir: string): Promise<string> {
await mkdir(pkgDir, { recursive: true });
const pi = this.skillsOnly
? { skills: ["SKILL.md"] }
: { extensions: ["index.js"] };
await writeFile(
path.join(pkgDir, "package.json"),
JSON.stringify({ name: ref, version: "1.0.0", pi }),
);
if (this.skillsOnly) {
await writeFile(path.join(pkgDir, "SKILL.md"), "# Notes");
} else {
await writeFile(path.join(pkgDir, "index.js"), PROBE_EXTENSION);
}
return pkgDir;
}
}

test("probes and records an extension's own registered tools", async (t) => {
const store = new FileExtensionStore(path.join(ROOT, "undeclared"));
const registry = new MarketplaceRegistry();
registry.register(new UndeclaredMarketplace());
const service = new GlobalExtensionService(registry, store);

const meta = await service.installFromSource("undeclared:web");
if (meta.tools.length === 1 && meta.tools[0] === "*") {
t.skip("pi binary not resolvable; probe fell back to wildcard");
return;
}
assert.deepEqual([...meta.tools].sort(), ["alpha_tool", "beta_tool"]);
assert.deepEqual(service.spawnPlan(["web"]).allowlistTools, meta.tools);
});

test("records no tools for a skills-only package", async () => {
const store = new FileExtensionStore(path.join(ROOT, "skills-only"));
const registry = new MarketplaceRegistry();
registry.register(new UndeclaredMarketplace(true));
const service = new GlobalExtensionService(registry, store);

const meta = await service.installFromSource("undeclared:notes");
assert.deepEqual(meta.tools, []);
});

test("rejects a duplicate install unless replace is set", async () => {
const { service } = makeService("conflict");
await service.installFromSource("fake:dup");

await assert.rejects(
service.installFromSource("fake:dup"),
(err: unknown) => err instanceof ExtensionConflictError,
);

const replaced = await service.installFromSource("fake:dup", {
replace: true,
});
assert.equal(replaced.id, "dup");
});

test("spawnPlan skips unknown and errored ids, deduping the rest", async () => {
const { service } = makeService("spawn");
await service.installFromSource("fake:good");

const plan = service.spawnPlan(["good", "good", "missing"]);
assert.equal(plan.extensionPaths.length, 1);
assert.ok(
plan.extensionPaths[0].endsWith(path.join("good", "pkg", "index.ts")),
);
assert.deepEqual(plan.allowlistTools, ["fake_tool"]);
});

test("reconcile reinstalls an extension whose package files went missing", async () => {
const { service, store, fake } = makeService("reconcile");
await service.installFromSource("fake:survivor");
assert.equal(fake.installs, 1);

await rm(store.pkgDir("survivor"), { recursive: true, force: true });
assert.ok(!existsSync(path.join(store.pkgDir("survivor"), "index.ts")));

const reinstalled = await service.reconcile();
assert.equal(reinstalled, 1);
assert.equal(fake.installs, 2);
assert.ok(existsSync(path.join(store.pkgDir("survivor"), "index.ts")));
assert.deepEqual(service.readyIds(), ["survivor"]);
});

test("reconcile marks an install errored when its reinstall fails", async () => {
const { service, store, fake } = makeService("reconcile-fail");
await service.installFromSource("fake:fragile");
await rm(store.pkgDir("fragile"), { recursive: true, force: true });

fake.fail = true;
const reinstalled = await service.reconcile();

assert.equal(reinstalled, 0);
assert.deepEqual(service.readyIds(), []);
assert.equal(service.list()[0]?.status, "error");
assert.equal(service.spawnPlan(["fragile"]).extensionPaths.length, 0);
});

test("uninstall removes an install and clears it from the cache", async () => {
const { service, store } = makeService("uninstall");
await service.installFromSource("fake:temp");

assert.equal(await service.uninstall("temp"), true);
assert.equal(await service.uninstall("temp"), false);
assert.equal(service.list().length, 0);
assert.ok(!existsSync(store.dir("temp")));
});
Loading
Loading