From 69458e7a9cc87394bbd83e1eaba7626e91bd1a44 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Mon, 2 Nov 2026 13:42:45 -0800 Subject: [PATCH] feat: global extensions api --- apps/server/src/config.ts | 14 + .../src/extensions/fileExtensionStore.ts | 136 +++ .../extensions/globalExtensionService.test.ts | 198 ++++ .../src/extensions/globalExtensionService.ts | 514 ++++++++ .../marketplaces/fileMarketplace.test.ts | 80 ++ .../marketplaces/fileMarketplace.ts | 124 ++ .../extensions/marketplaces/gitMarketplace.ts | 35 + .../src/extensions/marketplaces/npmInstall.ts | 95 ++ .../marketplaces/npmMarketplace.test.ts | 53 + .../extensions/marketplaces/npmMarketplace.ts | 56 + .../extensions/marketplaces/registry.test.ts | 45 + .../src/extensions/marketplaces/registry.ts | 56 + .../src/extensions/packageResources.test.ts | 118 ++ .../server/src/extensions/packageResources.ts | 405 +++++++ apps/server/src/extensions/probeTools.test.ts | 40 + apps/server/src/extensions/probeTools.ts | 178 +++ apps/server/src/extensions/selection.test.ts | 114 ++ apps/server/src/extensions/selection.ts | 64 + .../src/extensions/sessionExtensions.ts | 48 + apps/server/src/extensions/types.ts | 56 + apps/server/src/index.ts | 33 + apps/server/src/pi/agentConfig.ts | 7 + apps/server/src/pi/config/bundleLoader.ts | 3 + apps/server/src/pi/config/manifest.ts | 18 + apps/server/src/pi/piAgentManager.test.ts | 1 + apps/server/src/pi/piAgentManager.ts | 122 +- apps/server/src/routes/globalExtensions.ts | 240 ++++ apps/server/src/routes/sessions/handlers.ts | 134 ++- apps/server/src/routes/sessions/index.ts | 15 + apps/server/src/routes/sessions/schemas.ts | 22 + .../store/db/migrations/0027_secret_ink.sql | 1 + .../db/migrations/meta/0027_snapshot.json | 1056 +++++++++++++++++ .../store/db/migrations/meta/_journal.json | 7 + apps/server/src/store/db/schema.ts | 3 + apps/server/src/store/inMemorySessionStore.ts | 19 + apps/server/src/store/sessionStore.ts | 13 + .../src/store/sqliteSessionStore.test.ts | 43 + apps/server/src/store/sqliteSessionStore.ts | 38 + docs/config-bundle-format.md | 120 +- docs/server/extensions-and-prompts.md | 119 ++ packages/shared/src/configBundle.ts | 13 +- packages/shared/src/contracts.ts | 144 +++ 42 files changed, 4547 insertions(+), 53 deletions(-) create mode 100644 apps/server/src/extensions/fileExtensionStore.ts create mode 100644 apps/server/src/extensions/globalExtensionService.test.ts create mode 100644 apps/server/src/extensions/globalExtensionService.ts create mode 100644 apps/server/src/extensions/marketplaces/fileMarketplace.test.ts create mode 100644 apps/server/src/extensions/marketplaces/fileMarketplace.ts create mode 100644 apps/server/src/extensions/marketplaces/gitMarketplace.ts create mode 100644 apps/server/src/extensions/marketplaces/npmInstall.ts create mode 100644 apps/server/src/extensions/marketplaces/npmMarketplace.test.ts create mode 100644 apps/server/src/extensions/marketplaces/npmMarketplace.ts create mode 100644 apps/server/src/extensions/marketplaces/registry.test.ts create mode 100644 apps/server/src/extensions/marketplaces/registry.ts create mode 100644 apps/server/src/extensions/packageResources.test.ts create mode 100644 apps/server/src/extensions/packageResources.ts create mode 100644 apps/server/src/extensions/probeTools.test.ts create mode 100644 apps/server/src/extensions/probeTools.ts create mode 100644 apps/server/src/extensions/selection.test.ts create mode 100644 apps/server/src/extensions/selection.ts create mode 100644 apps/server/src/extensions/sessionExtensions.ts create mode 100644 apps/server/src/extensions/types.ts create mode 100644 apps/server/src/routes/globalExtensions.ts create mode 100644 apps/server/src/store/db/migrations/0027_secret_ink.sql create mode 100644 apps/server/src/store/db/migrations/meta/0027_snapshot.json diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 4c0107a3..9c593d10 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -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/`) 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 diff --git a/apps/server/src/extensions/fileExtensionStore.ts b/apps/server/src/extensions/fileExtensionStore.ts new file mode 100644 index 00000000..8eb988ae --- /dev/null +++ b/apps/server/src/extensions/fileExtensionStore.ts @@ -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 `/` 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//` 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 { + 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 { + 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 { + 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 { + 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 { + await writeFile( + path.join(dir, META_FILENAME), + JSON.stringify(meta, null, 2), + ); + } + + /** Atomically swaps a staged install into `/`, replacing any prior. */ + async commit(staging: string, id: string): Promise { + 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 { + await rm(staging, { recursive: true, force: true }); + } + + /** The first preserved upload under an install's `source/`, if any. */ + async sourceFile(id: string): Promise { + try { + const files = await readdir(this.sourceDir(id)); + return files[0] ? path.join(this.sourceDir(id), files[0]) : undefined; + } catch { + return undefined; + } + } +} diff --git a/apps/server/src/extensions/globalExtensionService.test.ts b/apps/server/src/extensions/globalExtensionService.test.ts new file mode 100644 index 00000000..595266f3 --- /dev/null +++ b/apps/server/src/extensions/globalExtensionService.test.ts @@ -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 { + 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 { + 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"))); +}); diff --git a/apps/server/src/extensions/globalExtensionService.ts b/apps/server/src/extensions/globalExtensionService.ts new file mode 100644 index 00000000..b28404ca --- /dev/null +++ b/apps/server/src/extensions/globalExtensionService.ts @@ -0,0 +1,514 @@ +import { existsSync } from "node:fs"; +import { copyFile, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import type { + GlobalExtensionMeta, + GlobalExtensionResources, + MarketplaceListing, +} from "@tangent/shared/contracts.ts"; + +import { + FileExtensionStore, + isUnsafeId, + PKG_DIRNAME, + SOURCE_DIRNAME, +} from "./fileExtensionStore.ts"; +import type { MarketplaceRegistry } from "./marketplaces/registry.ts"; +import { + declaredTools, + readPackageManifest, + resolvePackageResources, +} from "./packageResources.ts"; +import { probeExtensionTools } from "./probeTools.ts"; +import type { ExtensionMarketplace, ExtensionSpawnPlan } from "./types.ts"; + +/** Raised when an install id already exists and the caller didn't opt to replace. */ +export class ExtensionConflictError extends Error {} + +/** Raised when a source, upload, or installed package is invalid. */ +export class ExtensionValidationError extends Error {} + +/** Normalized inputs to an install run, from a source string or an upload. */ +interface InstallRun { + marketplace: ExtensionMarketplace; + /** Install ref the marketplace understands (npm spec, git url, or filename). */ + ref: string; + /** Preserved-upload bytes for a `file` source. */ + upload?: { filename: string; data: Buffer }; + /** An existing preserved-source file to reinstall from (reconcile path). */ + sourcePath?: string; + idOverride?: string; + tools?: string[]; + replace: boolean; + /** First-install timestamp to preserve across a reinstall. */ + installedAt?: string; +} + +/** Options shared by the public install entry points. */ +export interface InstallOptions { + id?: string; + tools?: string[]; + replace?: boolean; +} + +/** + * Lowercases and slugs a package name into a storage id: a scoped name + * (`@scope/foo`) becomes `scope-foo`, every other invalid run collapses to a + * single `-`, and leading/trailing dashes are trimmed. + */ +export function slugifyExtensionId(name: string): string { + const slug = name + .toLowerCase() + .replace(/^@/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug; +} + +/** A parsed package manifest (or `undefined` when the package carries none). */ +type Manifest = Awaited>; + +/** Rejects a package that contributes no Pi resources. */ +function assertHasResources(resources: GlobalExtensionResources): void { + if ( + resources.extensions.length === 0 && + resources.skills.length === 0 && + resources.prompts.length === 0 + ) { + throw new ExtensionValidationError( + "package declares no extensions, skills, or prompts", + ); + } +} + +/** Whether an id is a safe, single-segment slug. */ +function isValidId(id: string): boolean { + return !isUnsafeId(id) && /^[a-z0-9][a-z0-9-]*$/.test(id); +} + +/** The storage id for an install: an override, the package name, or the ref. */ +function resolveInstallId(run: InstallRun, manifest: Manifest): string { + const id = slugifyExtensionId(run.idOverride ?? manifest?.name ?? run.ref); + if (!isValidId(id)) { + throw new ExtensionValidationError(`invalid extension id "${id}"`); + } + return id; +} + +/** The package root relative to `pkgDir`, as a POSIX path (`.` when equal). */ +function packageRootOf(pkgDir: string, root: string): string { + return path.relative(pkgDir, root).split(path.sep).join("/") || "."; +} + +/** The install timestamp to keep: an explicit override, a prior install, or now. */ +function firstInstalledAt( + run: InstallRun, + existing: GlobalExtensionMeta | undefined, + now: string, +): string { + return run.installedAt ?? existing?.installedAt ?? now; +} + +/** Absolute paths of a package's resolved extension entries. */ +function absExtensionPaths( + root: string, + resources: GlobalExtensionResources, +): string[] { + return resources.extensions.map((rel) => path.join(root, rel)); +} + +/** + * The tool allowlist to record for an install. An explicit list (admin-chosen, + * or carried across a reinstall) wins, then the package's declared tools + * (`tangent.tools` / `pi.tools`). Otherwise the extension is loaded in a + * subprocess to enumerate exactly the tools it registers, so opting in grants + * those and nothing more (an empty result means it registers none). Only when + * the probe can't run at all does a package that ships extension code fall back + * to `*`; a skills/prompts-only package records none. + */ +async function resolveInstallTools( + run: InstallRun, + manifest: Manifest, + resources: GlobalExtensionResources, + root: string, +): Promise { + if (run.tools) return run.tools; + const declared = declaredTools(manifest); + if (declared.length > 0) return declared; + if (resources.extensions.length === 0) return []; + const probed = await probeExtensionTools( + absExtensionPaths(root, resources), + root, + ); + return probed ?? ["*"]; +} + +/** Inputs to {@link buildMeta}. */ +interface BuildMetaArgs { + run: InstallRun; + id: string; + manifest: Manifest; + resources: GlobalExtensionResources; + tools: string[]; + pkgDir: string; + root: string; + existing: GlobalExtensionMeta | undefined; +} + +/** Assembles the persisted metadata for a freshly staged install. */ +function buildMeta(args: BuildMetaArgs): GlobalExtensionMeta { + const { run, id, manifest, resources, tools, pkgDir, root, existing } = args; + const now = new Date().toISOString(); + return { + id, + name: manifest?.name ?? id, + version: manifest?.version, + source: { marketplace: run.marketplace.id, ref: run.ref }, + tools, + resources, + packageRoot: packageRootOf(pkgDir, root), + installedAt: firstInstalledAt(run, existing, now), + updatedAt: now, + status: "ready", + }; +} + +/** + * Owns the global extension registry: installs from a {@link + * MarketplaceRegistry}, persists through a {@link FileExtensionStore}, and + * projects the installed set onto a Pi spawn. An in-memory cache keeps {@link + * GlobalExtensionService.spawnPlan} synchronous (the agent manager calls it on + * the hot spawn path) and {@link GlobalExtensionService.reconcile} seeds it at + * boot, reinstalling anything the volume is missing so an install survives a + * pod restart. + */ +export class GlobalExtensionService { + private readonly registry: MarketplaceRegistry; + private readonly store: FileExtensionStore; + private readonly cache = new Map(); + + constructor(registry: MarketplaceRegistry, store: FileExtensionStore) { + this.registry = registry; + this.store = store; + } + + /** Installs from a `":"` source (npm/git). */ + async installFromSource( + source: string, + options: InstallOptions = {}, + ): Promise { + const { marketplace, ref } = this.registry.parseSource(source); + return this.runInstall({ + marketplace, + ref, + idOverride: options.id, + tools: options.tools, + replace: options.replace ?? false, + }); + } + + /** Installs from an uploaded file (the `file` marketplace). */ + async installFromUpload( + filename: string, + data: Buffer, + options: InstallOptions = {}, + ): Promise { + const marketplace = this.registry.get("file"); + if (!marketplace) { + throw new ExtensionValidationError("file marketplace is not registered"); + } + return this.runInstall({ + marketplace, + ref: filename, + upload: { filename, data }, + idOverride: options.id, + tools: options.tools, + replace: options.replace ?? false, + }); + } + + /** Removes an installed extension. Returns `false` when it was unknown. */ + async uninstall(id: string): Promise { + const deleted = await this.store.delete(id); + if (deleted) this.cache.delete(id); + return deleted; + } + + /** Patches an install's tool allowlist and/or display name in place. */ + async update( + id: string, + patch: { tools?: string[]; name?: string }, + ): Promise { + const existing = this.cache.get(id) ?? (await this.store.get(id)); + if (!existing) return undefined; + const next: GlobalExtensionMeta = { + ...existing, + tools: patch.tools ?? existing.tools, + name: patch.name ?? existing.name, + updatedAt: new Date().toISOString(), + }; + await this.store.writeMeta(this.store.dir(id), next); + this.cache.set(id, next); + return next; + } + + /** The installed extensions, newest status first reflected from the cache. */ + list(): GlobalExtensionMeta[] { + return [...this.cache.values()].sort((a, b) => a.id.localeCompare(b.id)); + } + + /** The ids of every extension currently healthy enough to spawn. */ + readyIds(): string[] { + return this.list() + .filter((meta) => meta.status === "ready") + .map((meta) => meta.id); + } + + /** The marketplaces available for install/search (id + search support). */ + marketplaces(): { id: string; canSearch: boolean }[] { + return this.registry.list().map((marketplace) => ({ + id: marketplace.id, + canSearch: typeof marketplace.search === "function", + })); + } + + /** Searches one marketplace, or `undefined` when it has no search support. */ + async search( + marketplaceId: string, + query: string, + ): Promise { + const marketplace = this.registry.get(marketplaceId); + if (!marketplace?.search) return undefined; + return marketplace.search(query); + } + + /** + * Loads the persisted installs into the cache and reinstalls any whose + * resource files are missing from the volume (e.g. a fresh pod with a reused + * data volume, or an interrupted write). Never throws: a failed reinstall is + * logged and the install is kept as `error` so a later retry can recover it. + * Returns how many were reinstalled. + */ + async reconcile(): Promise { + this.cache.clear(); + const metas = await this.store.list(); + let reinstalled = 0; + for (const meta of metas) { + this.cache.set(meta.id, meta); + if (this.resourcesPresent(meta)) { + this.setStatus(meta.id, "ready"); + continue; + } + const recovered = await this.tryReinstall(meta); + if (recovered) reinstalled += 1; + } + return reinstalled; + } + + /** + * The spawn plan for a resolved set of extension ids: the absolute resource + * paths and tool allowlist to fold into a Pi spawn. Unknown or errored ids are + * skipped with a warning so one bad install never blocks a session. + */ + spawnPlan(ids: string[]): ExtensionSpawnPlan { + const plan: ExtensionSpawnPlan = { + extensionPaths: [], + skillPaths: [], + promptPaths: [], + allowlistTools: [], + }; + for (const id of ids) { + const meta = this.cache.get(id); + if (!meta) { + console.warn( + `[extensions] session references unknown extension "${id}"`, + ); + continue; + } + if (meta.status !== "ready") { + console.warn(`[extensions] skipping "${id}" (status: ${meta.status})`); + continue; + } + const abs = this.resolveAbsResources(meta); + plan.extensionPaths.push(...abs.extensions); + plan.skillPaths.push(...abs.skills); + plan.promptPaths.push(...abs.prompts); + plan.allowlistTools.push(...meta.tools); + } + return { + extensionPaths: [...new Set(plan.extensionPaths)], + skillPaths: [...new Set(plan.skillPaths)], + promptPaths: [...new Set(plan.promptPaths)], + allowlistTools: [...new Set(plan.allowlistTools)], + }; + } + + /** The absolute package root for an install (`pkg/` joined with packageRoot). */ + private packageRootDir(meta: GlobalExtensionMeta): string { + const base = this.store.pkgDir(meta.id); + return meta.packageRoot === "." + ? base + : path.join(base, ...meta.packageRoot.split("/")); + } + + /** Resolves an install's package-relative resources to absolute paths. */ + private resolveAbsResources(meta: GlobalExtensionMeta): { + extensions: string[]; + skills: string[]; + prompts: string[]; + } { + const root = this.packageRootDir(meta); + const toAbs = (rel: string) => + rel === "." ? root : path.join(root, ...rel.split("/")); + return { + extensions: meta.resources.extensions.map(toAbs), + skills: meta.resources.skills.map(toAbs), + prompts: meta.resources.prompts.map(toAbs), + }; + } + + /** Whether every resolved resource path for an install exists on disk. */ + private resourcesPresent(meta: GlobalExtensionMeta): boolean { + const abs = this.resolveAbsResources(meta); + return [...abs.extensions, ...abs.skills, ...abs.prompts].every((p) => + existsSync(p), + ); + } + + /** Reinstalls a missing install from its recorded source; `false` on failure. */ + private async tryReinstall(meta: GlobalExtensionMeta): Promise { + const marketplace = this.registry.get(meta.source.marketplace); + if (!marketplace) { + this.markError( + meta.id, + `unknown marketplace "${meta.source.marketplace}"`, + ); + return false; + } + const resolved = await this.reinstallSourcePath(meta); + if (resolved.error) { + this.markError(meta.id, resolved.error); + return false; + } + return this.runReinstall(meta, marketplace, resolved.sourcePath); + } + + /** The preserved `file` upload to reinstall from (none for fetched sources). */ + private async reinstallSourcePath( + meta: GlobalExtensionMeta, + ): Promise<{ sourcePath?: string; error?: string }> { + if (meta.source.marketplace !== "file") return {}; + const sourcePath = await this.store.sourceFile(meta.id); + if (!sourcePath) return { error: "preserved source upload is missing" }; + return { sourcePath }; + } + + /** Runs a recovery reinstall, keeping the install `error` on failure. */ + private async runReinstall( + meta: GlobalExtensionMeta, + marketplace: ExtensionMarketplace, + sourcePath: string | undefined, + ): Promise { + try { + await this.runInstall({ + marketplace, + ref: meta.source.ref, + sourcePath, + idOverride: meta.id, + tools: meta.tools, + replace: true, + installedAt: meta.installedAt, + }); + console.log(`[extensions] reinstalled "${meta.id}"`); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.markError(meta.id, message); + console.error( + `[extensions] failed to reinstall "${meta.id}": ${message}`, + ); + return false; + } + } + + /** Overlays a runtime status onto the cached meta (no disk write). */ + private setStatus(id: string, status: "ready" | "error"): void { + const meta = this.cache.get(id); + if (meta) this.cache.set(id, { ...meta, status, error: undefined }); + } + + /** Marks a cached install `error` with a reason. */ + private markError(id: string, error: string): void { + const meta = this.cache.get(id); + if (meta) this.cache.set(id, { ...meta, status: "error", error }); + } + + /** Installs into a staging dir, validates, and swaps it in atomically. */ + private async runInstall(run: InstallRun): Promise { + const staging = await this.store.makeStaging(); + try { + const meta = await this.installIntoStaging(staging, run); + await this.store.commit(staging, meta.id); + this.cache.set(meta.id, meta); + return meta; + } catch (err) { + await this.store.discard(staging); + throw err; + } + } + + /** Installs and validates into the staging dir, returning its metadata. */ + private async installIntoStaging( + staging: string, + run: InstallRun, + ): Promise { + const pkgDir = path.join(staging, PKG_DIRNAME); + const sourcePath = await this.stageSource(staging, run); + const root = await run.marketplace.install(run.ref, pkgDir, { sourcePath }); + + const resources = await resolvePackageResources(root); + assertHasResources(resources); + + const manifest = await readPackageManifest(root); + const id = resolveInstallId(run, manifest); + const existing = this.cache.get(id) ?? (await this.store.get(id)); + if (existing && !run.replace) { + throw new ExtensionConflictError( + `extension "${id}" is already installed`, + ); + } + + const tools = await resolveInstallTools(run, manifest, resources, root); + const meta = buildMeta({ + run, + id, + manifest, + resources, + tools, + pkgDir, + root, + existing, + }); + await this.store.writeMeta(staging, meta); + return meta; + } + + /** Writes the upload (or copies an existing source) into the staging `source/`. */ + private async stageSource( + staging: string, + run: InstallRun, + ): Promise { + if (!run.upload && !run.sourcePath) return undefined; + const dir = path.join(staging, SOURCE_DIRNAME); + await mkdir(dir, { recursive: true }); + if (run.upload) { + const dest = path.join(dir, path.basename(run.upload.filename)); + await writeFile(dest, run.upload.data); + return dest; + } + const dest = path.join(dir, path.basename(run.sourcePath as string)); + await copyFile(run.sourcePath as string, dest); + return dest; + } +} diff --git a/apps/server/src/extensions/marketplaces/fileMarketplace.test.ts b/apps/server/src/extensions/marketplaces/fileMarketplace.test.ts new file mode 100644 index 00000000..5e0b30be --- /dev/null +++ b/apps/server/src/extensions/marketplaces/fileMarketplace.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +import { zipSync } from "fflate"; + +import { resolvePackageResources } from "../packageResources.ts"; +import { FileMarketplace } from "./fileMarketplace.ts"; + +const ROOT = mkdtempSync(path.join(tmpdir(), "file-marketplace-")); +after(() => rmSync(ROOT, { recursive: true, force: true })); + +const encoder = new TextEncoder(); + +/** Writes a zip of `files` to a temp path and returns it. */ +function writeZip(name: string, files: Record): string { + const entries: Record = {}; + for (const [rel, content] of Object.entries(files)) { + entries[rel] = encoder.encode(content); + } + const zipPath = path.join(ROOT, name); + writeFileSync(zipPath, zipSync(entries)); + return zipPath; +} + +test("installs an extension from a .zip and resolves its resources", async () => { + const zipPath = writeZip("ext.zip", { + "package.json": JSON.stringify({ + name: "zipped-ext", + version: "0.1.0", + pi: { extensions: ["index.ts"] }, + }), + "index.ts": "// extension", + }); + const pkgDir = path.join(ROOT, "install-zip", "pkg"); + + const root = await new FileMarketplace().install("ext.zip", pkgDir, { + sourcePath: zipPath, + }); + + assert.equal(root, pkgDir); + assert.ok(existsSync(path.join(pkgDir, "index.ts"))); + const resources = await resolvePackageResources(root); + assert.deepEqual(resources.extensions, ["index.ts"]); +}); + +test("rejects a .zip whose entry escapes the install directory", async () => { + const zipPath = writeZip("evil.zip", { + "../escape.ts": "// traversal", + }); + const pkgDir = path.join(ROOT, "install-evil", "pkg"); + + await assert.rejects( + new FileMarketplace().install("evil.zip", pkgDir, { sourcePath: zipPath }), + /unsafe entry path/, + ); + assert.ok(!existsSync(path.join(ROOT, "install-evil", "escape.ts"))); +}); + +test("copies a single-file extension source", async () => { + const src = path.join(ROOT, "solo.ts"); + writeFileSync(src, "// solo extension"); + const pkgDir = path.join(ROOT, "install-solo", "pkg"); + + const root = await new FileMarketplace().install("solo.ts", pkgDir, { + sourcePath: src, + }); + + assert.equal(root, pkgDir); + assert.ok(existsSync(path.join(pkgDir, "solo.ts"))); +}); + +test("throws without a source path", async () => { + await assert.rejects( + new FileMarketplace().install("x.zip", path.join(ROOT, "nope"), {}), + /requires an uploaded file/, + ); +}); diff --git a/apps/server/src/extensions/marketplaces/fileMarketplace.ts b/apps/server/src/extensions/marketplaces/fileMarketplace.ts new file mode 100644 index 00000000..6ee90805 --- /dev/null +++ b/apps/server/src/extensions/marketplaces/fileMarketplace.ts @@ -0,0 +1,124 @@ +import { existsSync } from "node:fs"; +import { + copyFile, + mkdir, + readdir, + readFile, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { unzipSync } from "fflate"; + +import type { ExtensionMarketplace, InstallContext } from "../types.ts"; +import { + installedPackageRoot, + npmInstall, + npmInstallDeps, +} from "./npmInstall.ts"; + +/** Archive extensions npm can install directly as a tarball. */ +const TARBALL_EXTS = new Set([".tgz", ".tar", ".gz"]); + +/** Single-file extension source extensions. */ +const SINGLE_FILE_EXTS = new Set([".ts", ".js", ".mjs", ".cjs"]); + +/** + * A zip entry path is safe when it is a non-empty relative POSIX path with no + * `..` traversal and no absolute/drive prefix. Mirrors the bundle loader's + * guard so a malicious archive can't escape the install directory. + */ +function isSafeEntryPath(value: string): boolean { + if (value.length === 0) return false; + if (value.startsWith("/") || /^[a-zA-Z]:/.test(value)) return false; + return value + .split("/") + .every((segment) => segment !== ".." && segment !== ""); +} + +/** Extracts a zip into `pkgDir`, rejecting traversal and directory entries. */ +async function extractZip(zipPath: string, pkgDir: string): Promise { + const buffer = await readFile(zipPath); + const entries = unzipSync(new Uint8Array(buffer)); + await mkdir(pkgDir, { recursive: true }); + for (const [name, data] of Object.entries(entries)) { + if (name.endsWith("/")) continue; + if (!isSafeEntryPath(name)) { + throw new Error(`extension archive: unsafe entry path "${name}"`); + } + const abs = path.join(pkgDir, ...name.split("/")); + await mkdir(path.dirname(abs), { recursive: true }); + await writeFile(abs, data); + } +} + +/** + * Resolves the package root inside an extracted zip: `pkgDir` itself when it + * holds the `package.json` (or any resource), else a single top-level + * subdirectory that does. Falls back to `pkgDir`. + */ +async function resolveExtractedRoot(pkgDir: string): Promise { + if (existsSync(path.join(pkgDir, "package.json"))) return pkgDir; + const entries = await readdir(pkgDir, { withFileTypes: true }); + const dirs = entries.filter((entry) => entry.isDirectory()); + const files = entries.filter((entry) => entry.isFile()); + if (dirs.length === 1 && files.length === 0) { + return path.join(pkgDir, dirs[0].name); + } + return pkgDir; +} + +/** Installs the extracted package's own dependencies, when it declares any. */ +async function installExtractedDeps(root: string): Promise { + const manifestPath = path.join(root, "package.json"); + if (!existsSync(manifestPath)) return; + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { + dependencies?: Record; + }; + if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) { + await npmInstallDeps(root); + } +} + +/** + * The file marketplace: installs an uploaded extension from the preserved + * `source/` file. A `.tgz`/tarball is handed to npm; a `.zip` is extracted + * (with a traversal guard) and its dependencies installed; a lone `.ts`/`.js` + * file is copied as a single extension. `ref` is the original filename, used + * only for display/reinstall. + */ +export class FileMarketplace implements ExtensionMarketplace { + readonly id = "file"; + + async install( + _ref: string, + pkgDir: string, + ctx: InstallContext, + ): Promise { + const src = ctx.sourcePath; + if (!src) { + throw new Error("file marketplace requires an uploaded file"); + } + const ext = path.extname(src).toLowerCase(); + + if (TARBALL_EXTS.has(ext)) { + const name = await npmInstall(pkgDir, src); + return installedPackageRoot(pkgDir, name); + } + + if (ext === ".zip") { + await extractZip(src, pkgDir); + const root = await resolveExtractedRoot(pkgDir); + await installExtractedDeps(root); + return root; + } + + if (SINGLE_FILE_EXTS.has(ext)) { + await mkdir(pkgDir, { recursive: true }); + await copyFile(src, path.join(pkgDir, path.basename(src))); + return pkgDir; + } + + throw new Error(`unsupported extension file type "${ext}"`); + } +} diff --git a/apps/server/src/extensions/marketplaces/gitMarketplace.ts b/apps/server/src/extensions/marketplaces/gitMarketplace.ts new file mode 100644 index 00000000..7e0ed5a9 --- /dev/null +++ b/apps/server/src/extensions/marketplaces/gitMarketplace.ts @@ -0,0 +1,35 @@ +import type { ExtensionMarketplace } from "../types.ts"; +import { installedPackageRoot, npmInstall } from "./npmInstall.ts"; + +/** + * Normalizes a git `ref` into a spec npm understands. A value already carrying + * a scheme (`git+`, `http(s)://`, `git://`) is passed through (prefixed with + * `git+` when bare); a bare `host/path@ref` becomes `git+https://host/path#ref`, + * so `github.com/user/repo@v1` installs the `v1` ref over HTTPS. + */ +export function toGitSpec(ref: string): string { + if ( + ref.startsWith("http://") || + ref.startsWith("https://") || + ref.startsWith("git://") + ) { + return `git+${ref}`; + } + if (ref.startsWith("git+")) return ref; + + const at = ref.lastIndexOf("@"); + if (at > 0) { + return `git+https://${ref.slice(0, at)}#${ref.slice(at + 1)}`; + } + return `git+https://${ref}`; +} + +/** The git marketplace: installs a repository via npm's git source support. */ +export class GitMarketplace implements ExtensionMarketplace { + readonly id = "git"; + + async install(ref: string, pkgDir: string): Promise { + const name = await npmInstall(pkgDir, toGitSpec(ref)); + return installedPackageRoot(pkgDir, name); + } +} diff --git a/apps/server/src/extensions/marketplaces/npmInstall.ts b/apps/server/src/extensions/marketplaces/npmInstall.ts new file mode 100644 index 00000000..b6debdde --- /dev/null +++ b/apps/server/src/extensions/marketplaces/npmInstall.ts @@ -0,0 +1,95 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** Upper bound on a single npm install (ms). */ +const NPM_INSTALL_TIMEOUT_MS = 120_000; + +/** Max bytes captured from npm's stdout/stderr before it is killed. */ +const NPM_MAX_BUFFER = 32 * 1024 * 1024; + +/** + * Installs an npm `spec` (a registry name, a git url, or a local tarball path) + * into `pkgDir` using the runtime `npm` on PATH, and returns the installed + * package name. + * + * This shells out to `npm`, not pnpm: the target is the persistent volume + * (`EXTENSIONS_ROOT`), not the repo workspace, so the repo's package manager is + * irrelevant here. A holder `package.json` is written first so npm records the + * one installed dependency, letting us read its name back (the key under + * `dependencies`) rather than guessing it from the spec — which a git url or a + * tarball doesn't carry. `--omit=peer` because Pi supplies the + * `@earendil-works/*` peers, matching how the Dockerfile installs pi-memory. + */ +export async function npmInstall( + pkgDir: string, + spec: string, +): Promise { + await mkdir(pkgDir, { recursive: true }); + await writeFile( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: "tangent-extension-host", + version: "0.0.0", + private: true, + }), + ); + + await execFileAsync( + "npm", + [ + "install", + "--prefix", + pkgDir, + "--omit=peer", + "--omit=dev", + "--no-fund", + "--no-audit", + "--package-lock=false", + spec, + ], + { timeout: NPM_INSTALL_TIMEOUT_MS, maxBuffer: NPM_MAX_BUFFER }, + ); + + const manifest = JSON.parse( + await readFile(path.join(pkgDir, "package.json"), "utf8"), + ) as { dependencies?: Record }; + const name = Object.keys(manifest.dependencies ?? {})[0]; + if (!name) { + throw new Error(`npm install produced no package for "${spec}"`); + } + return name; +} + +/** + * Installs the dependencies already declared in `dir`'s `package.json` (used + * after extracting an archive package that ships a dependency tree). No-op for + * a package with no dependencies. + */ +export async function npmInstallDeps(dir: string): Promise { + await execFileAsync( + "npm", + [ + "install", + "--prefix", + dir, + "--omit=peer", + "--omit=dev", + "--no-fund", + "--no-audit", + "--package-lock=false", + ], + { timeout: NPM_INSTALL_TIMEOUT_MS, maxBuffer: NPM_MAX_BUFFER }, + ); +} + +/** + * The absolute package root for a package installed under `pkgDir` by name. + * A scoped name (`@scope/foo`) nests two segments under `node_modules`. + */ +export function installedPackageRoot(pkgDir: string, name: string): string { + return path.join(pkgDir, "node_modules", ...name.split("/")); +} diff --git a/apps/server/src/extensions/marketplaces/npmMarketplace.test.ts b/apps/server/src/extensions/marketplaces/npmMarketplace.test.ts new file mode 100644 index 00000000..a624b059 --- /dev/null +++ b/apps/server/src/extensions/marketplaces/npmMarketplace.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { NpmMarketplace } from "./npmMarketplace.ts"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +interface StubPackage { + name: string; + version?: string; + description?: string; + keywords?: string[]; +} + +/** Stubs `fetch` with an npm search response and records the requested URL. */ +function stubSearch(packages: StubPackage[]): { getUrl: () => string } { + let url = ""; + globalThis.fetch = (async (input: Parameters[0]) => { + url = String(input); + return new Response( + JSON.stringify({ objects: packages.map((pkg) => ({ package: pkg })) }), + { status: 200 }, + ); + }) as typeof fetch; + return { getUrl: () => url }; +} + +test("drops results without the pi-package keyword", async () => { + stubSearch([ + { name: "pi-foo", version: "1.0.0", keywords: ["pi-package"] }, + { name: "random-lib", version: "2.0.0", keywords: ["other"] }, + { name: "no-keywords", version: "3.0.0" }, + ]); + + const listings = await new NpmMarketplace().search("foo"); + + assert.deepEqual( + listings.map((l) => l.name), + ["pi-foo"], + ); +}); + +test("scopes an empty query to the pi-package keyword", async () => { + const { getUrl } = stubSearch([]); + + await new NpmMarketplace().search(""); + + const text = new URL(getUrl()).searchParams.get("text"); + assert.equal(text, "keywords:pi-package"); +}); diff --git a/apps/server/src/extensions/marketplaces/npmMarketplace.ts b/apps/server/src/extensions/marketplaces/npmMarketplace.ts new file mode 100644 index 00000000..4bd4e915 --- /dev/null +++ b/apps/server/src/extensions/marketplaces/npmMarketplace.ts @@ -0,0 +1,56 @@ +import type { ExtensionMarketplace, MarketplaceListing } from "../types.ts"; +import { installedPackageRoot, npmInstall } from "./npmInstall.ts"; + +/** npm registry search endpoint, filtered to Pi packages. */ +const NPM_SEARCH_URL = "https://registry.npmjs.org/-/v1/search"; + +/** How many listings to request per search. */ +const SEARCH_SIZE = 25; + +/** Keyword a package must declare to appear in the Pi catalog. */ +const PI_PACKAGE_KEYWORD = "pi-package"; + +interface NpmSearchResult { + objects?: { + package?: { + name?: string; + description?: string; + version?: string; + keywords?: string[]; + }; + }[]; +} + +/** + * The npm marketplace: installs a registry package by spec (`pi-foo@1.2.0`, + * `@scope/foo`) and searches the public registry, scoped to the `pi-package` + * keyword so discovery surfaces Pi packages rather than every npm result. + */ +export class NpmMarketplace implements ExtensionMarketplace { + readonly id = "npm"; + + async install(ref: string, pkgDir: string): Promise { + const name = await npmInstall(pkgDir, ref); + return installedPackageRoot(pkgDir, name); + } + + async search(query: string): Promise { + const text = `${query} keywords:${PI_PACKAGE_KEYWORD}`.trim(); + const url = `${NPM_SEARCH_URL}?text=${encodeURIComponent(text)}&size=${SEARCH_SIZE}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`npm search failed: ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as NpmSearchResult; + return (data.objects ?? []) + .map((entry) => entry.package) + .filter((pkg): pkg is NonNullable => Boolean(pkg?.name)) + .filter((pkg) => pkg.keywords?.includes(PI_PACKAGE_KEYWORD)) + .map((pkg) => ({ + ref: pkg.name as string, + name: pkg.name as string, + description: pkg.description, + version: pkg.version, + })); + } +} diff --git a/apps/server/src/extensions/marketplaces/registry.test.ts b/apps/server/src/extensions/marketplaces/registry.test.ts new file mode 100644 index 00000000..b49c11fe --- /dev/null +++ b/apps/server/src/extensions/marketplaces/registry.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { createMarketplaceRegistry, MarketplaceRegistry } from "./registry.ts"; + +test("createMarketplaceRegistry registers npm, git, and file", () => { + const registry = createMarketplaceRegistry(); + assert.deepEqual( + registry + .list() + .map((marketplace) => marketplace.id) + .sort(), + ["file", "git", "npm"], + ); +}); + +test("parseSource splits on the first colon only", () => { + const registry = createMarketplaceRegistry(); + + const npm = registry.parseSource("npm:pi-foo@1.2.0"); + assert.equal(npm.marketplace.id, "npm"); + assert.equal(npm.ref, "pi-foo@1.2.0"); + + // A git url's own `:`/`@` stay in the ref. + const git = registry.parseSource("git:github.com/user/repo@v1"); + assert.equal(git.marketplace.id, "git"); + assert.equal(git.ref, "github.com/user/repo@v1"); + + // A scoped npm name keeps its leading `@`. + assert.equal(registry.parseSource("npm:@scope/foo").ref, "@scope/foo"); +}); + +test("parseSource rejects a missing separator, unknown source, or empty ref", () => { + const registry = createMarketplaceRegistry(); + assert.throws(() => registry.parseSource("pi-foo"), /expected/); + assert.throws( + () => registry.parseSource("bogus:thing"), + /unknown marketplace/, + ); + assert.throws(() => registry.parseSource("npm:"), /missing a ref/); +}); + +test("an empty registry has no marketplaces", () => { + assert.deepEqual(new MarketplaceRegistry().list(), []); +}); diff --git a/apps/server/src/extensions/marketplaces/registry.ts b/apps/server/src/extensions/marketplaces/registry.ts new file mode 100644 index 00000000..20ee375e --- /dev/null +++ b/apps/server/src/extensions/marketplaces/registry.ts @@ -0,0 +1,56 @@ +import type { ExtensionMarketplace, ParsedSource } from "../types.ts"; +import { FileMarketplace } from "./fileMarketplace.ts"; +import { GitMarketplace } from "./gitMarketplace.ts"; +import { NpmMarketplace } from "./npmMarketplace.ts"; + +/** + * Keyed set of {@link ExtensionMarketplace}s. A `":"` source string + * selects one; {@link MarketplaceRegistry.parseSource} splits it. "Marketplace" + * is intentionally an abstraction: a proprietary source is one new class + * registered here, with no change to the install or spawn path. + */ +export class MarketplaceRegistry { + private readonly byId = new Map(); + + register(marketplace: ExtensionMarketplace): void { + this.byId.set(marketplace.id, marketplace); + } + + get(id: string): ExtensionMarketplace | undefined { + return this.byId.get(id); + } + + list(): ExtensionMarketplace[] { + return [...this.byId.values()]; + } + + /** + * Splits `":"` into the resolved marketplace and its ref. + * Only the first `:` separates them, so a ref may itself contain `:` (e.g. a + * git url). Throws on an unknown marketplace, a missing separator, or an + * empty ref. + */ + parseSource(source: string): ParsedSource { + const separator = source.indexOf(":"); + if (separator === -1) { + throw new Error( + `invalid source "${source}" (expected ":")`, + ); + } + const id = source.slice(0, separator); + const ref = source.slice(separator + 1).trim(); + const marketplace = this.byId.get(id); + if (!marketplace) throw new Error(`unknown marketplace "${id}"`); + if (!ref) throw new Error(`source "${source}" is missing a ref`); + return { marketplace, ref }; + } +} + +/** Builds a registry with the built-in npm, git, and file marketplaces. */ +export function createMarketplaceRegistry(): MarketplaceRegistry { + const registry = new MarketplaceRegistry(); + registry.register(new NpmMarketplace()); + registry.register(new GitMarketplace()); + registry.register(new FileMarketplace()); + return registry; +} diff --git a/apps/server/src/extensions/packageResources.test.ts b/apps/server/src/extensions/packageResources.test.ts new file mode 100644 index 00000000..0a771060 --- /dev/null +++ b/apps/server/src/extensions/packageResources.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +import { + declaredTools, + readPackageManifest, + resolvePackageResources, +} from "./packageResources.ts"; + +const ROOT = mkdtempSync(path.join(tmpdir(), "pkg-resources-")); +after(() => rmSync(ROOT, { recursive: true, force: true })); + +/** Builds a throwaway package directory and returns its absolute path. */ +function makePackage(name: string, files: Record): string { + const dir = path.join(ROOT, name); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(dir, rel); + mkdirSync(path.dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + return dir; +} + +test("resolves a declared pi manifest (extensions + skills + prompts)", async () => { + const dir = makePackage("manifest", { + "package.json": JSON.stringify({ + name: "manifest-pkg", + version: "2.0.0", + pi: { + extensions: ["index.ts"], + skills: ["skills"], + prompts: ["prompts/*.md"], + }, + tangent: { tools: ["alpha", "beta"] }, + }), + "index.ts": "// extension", + "skills/one/SKILL.md": "# one", + "skills/two/SKILL.md": "# two", + "prompts/review.md": "review", + "prompts/plan.md": "plan", + }); + + const resources = await resolvePackageResources(dir); + assert.deepEqual(resources.extensions, ["index.ts"]); + assert.deepEqual(resources.skills, ["skills/one", "skills/two"]); + assert.deepEqual(resources.prompts, ["prompts/plan.md", "prompts/review.md"]); + + const manifest = await readPackageManifest(dir); + assert.deepEqual(declaredTools(manifest), ["alpha", "beta"]); +}); + +test("keeps a declared directory as one extension (not every file inside)", async () => { + // Mirrors pi-web-access: `pi.extensions: ["./dist"]`, where dist holds the + // real entry (index.js) beside a `bin` CLI (mcp-cli.js) with no factory. Pi + // resolves the directory's index, so we must emit the directory, not a file + // list that would make Pi try to load the CLI and crash. + const dir = makePackage("dir-extension", { + "package.json": JSON.stringify({ + name: "dir-extension-pkg", + pi: { extensions: ["./dist"] }, + }), + "dist/index.js": "// entry", + "dist/mcp-cli.js": "#!/usr/bin/env node", + }); + + const resources = await resolvePackageResources(dir); + assert.deepEqual(resources.extensions, ["dist"]); +}); + +test("still expands a glob extension pattern to matching code files", async () => { + const dir = makePackage("glob-extension", { + "package.json": JSON.stringify({ + name: "glob-extension-pkg", + pi: { extensions: ["ext/*.js"] }, + }), + "ext/a.js": "// a", + "ext/b.js": "// b", + "ext/notes.md": "doc", + }); + + const resources = await resolvePackageResources(dir); + assert.deepEqual(resources.extensions, ["ext/a.js", "ext/b.js"]); +}); + +test("falls back to conventional directories when no pi manifest", async () => { + const dir = makePackage("conventional", { + "package.json": JSON.stringify({ name: "conventional-pkg" }), + "extensions/a.ts": "// a", + "extensions/b.js": "// b", + "skills/solo/SKILL.md": "# solo", + "prompts/p.md": "p", + }); + + const resources = await resolvePackageResources(dir); + assert.deepEqual(resources.extensions, [ + "extensions/a.ts", + "extensions/b.js", + ]); + assert.deepEqual(resources.skills, ["skills/solo"]); + assert.deepEqual(resources.prompts, ["prompts/p.md"]); +}); + +test("treats a root SKILL.md as one skill", async () => { + const dir = makePackage("root-skill", { "SKILL.md": "# root" }); + const resources = await resolvePackageResources(dir); + assert.deepEqual(resources.skills, ["."]); + assert.deepEqual(resources.extensions, []); +}); + +test("treats a lone code file as one extension", async () => { + const dir = makePackage("single-file", { "tool.ts": "// tool" }); + const resources = await resolvePackageResources(dir); + assert.deepEqual(resources.extensions, ["tool.ts"]); + assert.deepEqual(resources.skills, []); +}); diff --git a/apps/server/src/extensions/packageResources.ts b/apps/server/src/extensions/packageResources.ts new file mode 100644 index 00000000..53542498 --- /dev/null +++ b/apps/server/src/extensions/packageResources.ts @@ -0,0 +1,405 @@ +import { type Dirent, existsSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import type { GlobalExtensionResources } from "@tangent/shared/contracts.ts"; + +/** Conventional resource directories Pi discovers when no manifest declares them. */ +const CONVENTIONAL_DIRS = { + extensions: "extensions", + skills: "skills", + prompts: "prompts", +} as const; + +/** File extensions treated as loadable Pi extensions. */ +const EXTENSION_EXTS = new Set([".ts", ".js", ".mjs", ".cjs"]); + +/** File extensions treated as prompt templates. */ +const PROMPT_EXTS = new Set([".md"]); + +/** The `pi` manifest block (and a Tangent tools hint) read from package.json. */ +interface PiPackageManifest { + pi?: { + extensions?: string[]; + skills?: string[]; + prompts?: string[]; + tools?: string[]; + }; + tangent?: { tools?: string[] }; + name?: string; + version?: string; +} + +/** The `pi` block of a manifest, or `undefined` when none is declared. */ +type PiBlock = PiPackageManifest["pi"]; + +/** Reads and parses a package root's `package.json`, or `undefined` if absent. */ +export async function readPackageManifest( + root: string, +): Promise { + const manifestPath = path.join(root, "package.json"); + if (!existsSync(manifestPath)) return undefined; + try { + return JSON.parse( + await readFile(manifestPath, "utf8"), + ) as PiPackageManifest; + } catch { + return undefined; + } +} + +/** A manifest block's declared tool names, if any. */ +function blockTools( + block: { tools?: string[] } | undefined, +): string[] | undefined { + return block?.tools; +} + +/** The tool names a package declares (`tangent.tools`, then `pi.tools`). */ +export function declaredTools( + manifest: PiPackageManifest | undefined, +): string[] { + return blockTools(manifest?.tangent) ?? blockTools(manifest?.pi) ?? []; +} + +/** Lists a directory's entries with file types, or `[]` when it is missing. */ +async function readdirSafe(dir: string): Promise { + try { + return await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +/** Directory names skipped by {@link walkFiles}. */ +function isIgnoredEntry(name: string): boolean { + return name === "node_modules" || name === ".git"; +} + +/** Recursively lists POSIX file paths under `dir`, skipping `node_modules`/`.git`. */ +async function walkFiles(dir: string, base: string = dir): Promise { + const entries = await readdirSafe(dir); + const files: string[] = []; + for (const entry of entries) { + if (isIgnoredEntry(entry.name)) continue; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walkFiles(abs, base))); + } else if (entry.isFile()) { + files.push(path.relative(base, abs).split(path.sep).join("/")); + } + } + return files; +} + +/** The regex fragment and extra index advance for a `*`/`**` at `glob[i]`. */ +function translateStar( + glob: string, + i: number, +): { fragment: string; advance: number } | undefined { + if (glob[i] !== "*") return undefined; + if (glob[i + 1] !== "*") return { fragment: "[^/]*", advance: 0 }; + return { fragment: ".*", advance: glob[i + 2] === "/" ? 2 : 1 }; +} + +/** The regex fragment for one non-`*` glob character. */ +function translateChar(char: string): string { + if (char === "?") return "[^/]"; + return /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char; +} + +/** Translates a glob (`*`, `**`, `?`) into an anchored RegExp over POSIX paths. */ +function globToRegExp(glob: string): RegExp { + let out = ""; + for (let i = 0; i < glob.length; i += 1) { + const star = translateStar(glob, i); + if (star) { + out += star.fragment; + i += star.advance; + continue; + } + out += translateChar(glob[i]); + } + return new RegExp(`^${out}$`); +} + +/** Strips a leading `./` and a trailing `/` from a manifest-relative path. */ +function normalizePattern(pattern: string): string { + return pattern.replace(/^\.\//, "").replace(/\/+$/, ""); +} + +/** Whether a normalized pattern contains a glob wildcard. */ +function hasWildcard(pattern: string): boolean { + return pattern.includes("*") || pattern.includes("?"); +} + +/** POSIX files under a root-relative directory whose extension is in `exts`. */ +function filesInDir( + relFiles: string[], + dir: string, + exts: Set, +): string[] { + const prefix = dir === "" ? "" : `${dir}/`; + return relFiles.filter( + (rel) => + rel.startsWith(prefix) && + (dir === "" + ? !rel.includes("/") + : !rel.slice(prefix.length).includes("/")) && + exts.has(path.extname(rel).toLowerCase()), + ); +} + +/** The files one prompt include pattern contributes: glob, file, or directory. */ +function matchInclude( + root: string, + relFiles: string[], + pattern: string, + exts: Set, +): string[] { + if (hasWildcard(pattern)) { + const regex = globToRegExp(pattern); + return relFiles.filter((rel) => regex.test(rel)); + } + if (!existsSync(path.join(root, pattern))) return []; + if (relFiles.includes(pattern)) return [pattern]; + return filesInDir(relFiles, pattern, exts); +} + +/** + * The extension(s) one include pattern contributes. A glob matches code files; + * a literal file or directory is kept as-is. Unlike prompts, a declared + * directory is a single extension (Pi resolves its `index.*` entry at load, or + * discovers the files inside when there is none), so we must not expand it into + * every file — that would wrongly pull in a package `bin` CLI or other + * non-extension code sitting beside the entry point. + */ +function matchExtensionInclude( + root: string, + relFiles: string[], + pattern: string, +): string[] { + if (hasWildcard(pattern)) { + const regex = globToRegExp(pattern); + return relFiles.filter( + (rel) => + regex.test(rel) && EXTENSION_EXTS.has(path.extname(rel).toLowerCase()), + ); + } + return existsSync(path.join(root, pattern)) ? [pattern] : []; +} + +/** + * Resolves an include/exclude pattern set into a sorted, deduped path list. A + * `!`-prefixed pattern excludes glob matches; `includeMatcher` turns each + * remaining pattern into the paths it contributes. + */ +function resolvePatternSet( + patterns: string[], + includeMatcher: (pattern: string) => string[], +): string[] { + const excludes = patterns + .filter((pattern) => pattern.startsWith("!")) + .map((pattern) => globToRegExp(normalizePattern(pattern.slice(1)))); + + const matched = new Set(); + for (const raw of patterns) { + if (raw.startsWith("!")) continue; + for (const rel of includeMatcher(normalizePattern(raw))) matched.add(rel); + } + return [...matched] + .filter((rel) => !excludes.some((regex) => regex.test(rel))) + .sort(); +} + +/** Resolves `prompts`-style patterns (a literal directory expands to files). */ +function resolveFilePatterns( + root: string, + relFiles: string[], + patterns: string[], + exts: Set, +): string[] { + return resolvePatternSet(patterns, (pattern) => + matchInclude(root, relFiles, pattern, exts), + ); +} + +/** Resolves `extensions` patterns (a literal directory stays a single entry). */ +function resolveExtensionPatterns( + root: string, + relFiles: string[], + patterns: string[], +): string[] { + return resolvePatternSet(patterns, (pattern) => + matchExtensionInclude(root, relFiles, pattern), + ); +} + +/** The child directories of `dir` that directly hold a `SKILL.md`. */ +async function childSkillDirs(dir: string): Promise { + const dirs: string[] = []; + for (const entry of await readdirSafe(dir)) { + if (!entry.isDirectory()) continue; + const child = path.join(dir, entry.name); + if (existsSync(path.join(child, "SKILL.md"))) dirs.push(child); + } + return dirs; +} + +/** Resolves absolute skill directories for one skill candidate path. */ +async function skillDirsFor(abs: string): Promise { + if (!existsSync(abs)) return []; + const info = await stat(abs); + if (info.isFile()) { + return path.basename(abs) === "SKILL.md" ? [path.dirname(abs)] : []; + } + if (!info.isDirectory()) return []; + if (existsSync(path.join(abs, "SKILL.md"))) return [abs]; + return childSkillDirs(abs); +} + +/** The absolute candidate paths one skill pattern points at. */ +function candidatePaths( + root: string, + relFiles: string[], + pattern: string, +): string[] { + if (!hasWildcard(pattern)) return [path.join(root, pattern)]; + const regex = globToRegExp(pattern); + return relFiles + .filter((rel) => regex.test(rel)) + .map((rel) => path.join(root, rel)); +} + +/** Every absolute candidate path the (non-excluding) skill patterns point at. */ +function skillCandidates( + root: string, + relFiles: string[], + patterns: string[], +): string[] { + const candidates = new Set(); + for (const raw of patterns) { + if (raw.startsWith("!")) continue; + for (const abs of candidatePaths(root, relFiles, normalizePattern(raw))) { + candidates.add(abs); + } + } + return [...candidates]; +} + +/** Resolves skill patterns into root-relative skill directory paths. */ +async function resolveSkillPatterns( + root: string, + relFiles: string[], + patterns: string[], +): Promise { + const dirs = new Set(); + for (const candidate of skillCandidates(root, relFiles, patterns)) { + for (const dir of await skillDirsFor(candidate)) { + dirs.add(relOrDot(root, dir)); + } + } + return [...dirs].sort(); +} + +/** POSIX relative path for an absolute dir under root (`.` when equal). */ +function relOrDot(root: string, abs: string): string { + return path.relative(root, abs).split(path.sep).join("/") || "."; +} + +/** The extension paths a package contributes: declared, else conventional. */ +function resolveExtensions( + root: string, + relFiles: string[], + pi: PiBlock, +): string[] { + if (pi?.extensions) { + return resolveExtensionPatterns(root, relFiles, pi.extensions); + } + return conventionalExtensions(root, relFiles); +} + +/** The skill paths a package contributes: declared, else conventional. */ +async function resolveSkills( + root: string, + relFiles: string[], + pi: PiBlock, +): Promise { + if (pi?.skills) return resolveSkillPatterns(root, relFiles, pi.skills); + return conventionalSkills(root); +} + +/** The prompt paths a package contributes: declared, else conventional. */ +function resolvePrompts( + root: string, + relFiles: string[], + pi: PiBlock, +): string[] { + if (pi?.prompts) { + return resolveFilePatterns(root, relFiles, pi.prompts, PROMPT_EXTS); + } + return conventionalPrompts(root, relFiles); +} + +/** + * Resolves a package root into the Pi resource paths it contributes, as POSIX + * paths relative to that root. Follows Pi's package rules: a declared `pi` + * manifest wins per resource type; an undeclared type falls back to its + * conventional directory; a package with none of those exposes a root + * `SKILL.md` as one skill and lone extension files as extensions. + */ +export async function resolvePackageResources( + root: string, +): Promise { + const manifest = await readPackageManifest(root); + const relFiles = await walkFiles(root); + const pi = manifest?.pi; + + const extensions = resolveExtensions(root, relFiles, pi); + const skills = await resolveSkills(root, relFiles, pi); + const prompts = resolvePrompts(root, relFiles, pi); + + if (extensions.length === 0 && skills.length === 0 && prompts.length === 0) { + return rootFallback(root, relFiles); + } + return { extensions, skills, prompts }; +} + +/** Conventional `extensions/` discovery: top-level code files. */ +function conventionalExtensions(root: string, relFiles: string[]): string[] { + const dir = path.join(root, CONVENTIONAL_DIRS.extensions); + if (!existsSync(dir)) return []; + return filesInDir( + relFiles, + CONVENTIONAL_DIRS.extensions, + EXTENSION_EXTS, + ).sort(); +} + +/** Conventional `skills/` discovery: skill directories holding a SKILL.md. */ +async function conventionalSkills(root: string): Promise { + const dir = path.join(root, CONVENTIONAL_DIRS.skills); + if (!existsSync(dir)) return []; + const dirs = await skillDirsFor(dir); + return dirs.map((abs) => relOrDot(root, abs)).sort(); +} + +/** Conventional `prompts/` discovery: top-level markdown files. */ +function conventionalPrompts(root: string, relFiles: string[]): string[] { + const dir = path.join(root, CONVENTIONAL_DIRS.prompts); + if (!existsSync(dir)) return []; + return filesInDir(relFiles, CONVENTIONAL_DIRS.prompts, PROMPT_EXTS).sort(); +} + +/** No-manifest, no-conventional-dir fallback: root SKILL.md / lone code files. */ +function rootFallback( + root: string, + relFiles: string[], +): GlobalExtensionResources { + const skills = existsSync(path.join(root, "SKILL.md")) ? ["."] : []; + const extensions = relFiles.filter( + (rel) => + !rel.includes("/") && EXTENSION_EXTS.has(path.extname(rel).toLowerCase()), + ); + return { extensions: extensions.sort(), skills, prompts: [] }; +} diff --git a/apps/server/src/extensions/probeTools.test.ts b/apps/server/src/extensions/probeTools.test.ts new file mode 100644 index 00000000..33d23f6c --- /dev/null +++ b/apps/server/src/extensions/probeTools.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +import { probeExtensionTools } from "./probeTools.ts"; + +const ROOT = mkdtempSync(path.join(tmpdir(), "probe-")); +after(() => rmSync(ROOT, { recursive: true, force: true })); + +const 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: {} }), + }); + } +} +`; + +test("enumerates the tools an extension registers", async (t) => { + const file = path.join(ROOT, "index.js"); + await writeFile(file, EXTENSION); + + const tools = await probeExtensionTools([file], ROOT); + if (tools === null) { + t.skip("pi binary not resolvable in this environment"); + return; + } + assert.deepEqual([...tools].sort(), ["alpha_tool", "beta_tool"]); +}); + +test("returns null for an empty path list without spawning", async () => { + assert.equal(await probeExtensionTools([], ROOT), null); +}); diff --git a/apps/server/src/extensions/probeTools.ts b/apps/server/src/extensions/probeTools.ts new file mode 100644 index 00000000..24eee29b --- /dev/null +++ b/apps/server/src/extensions/probeTools.ts @@ -0,0 +1,178 @@ +import { spawn } from "node:child_process"; +import { existsSync, readdirSync, realpathSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { PI_BIN } from "../config.ts"; + +const PNPM_PREFIX = "@earendil-works+pi-coding-agent@"; +const PKG_SUBPATH = "node_modules/@earendil-works/pi-coding-agent"; +const LOADER_SUBPATH = "dist/core/extensions/loader.js"; +const PROBE_TIMEOUT_MS = 20_000; + +/** + * A standalone ESM probe: it loads exactly the given extension paths through + * Pi's own loader (so `.ts`/`.js`/directory resolution matches a real spawn) + * and prints the union of their registered tool names. Kept as source rather + * than a shipped file so the server bundle needs no extra runtime asset. + */ +const PROBE_SOURCE = ` +import { pathToFileURL } from "node:url"; +const cfg = JSON.parse(Buffer.from(process.argv[2], "base64").toString("utf8")); +const loader = await import(pathToFileURL(cfg.loaderPath).href); +const res = await loader.loadExtensions(cfg.paths, cfg.cwd); +const tools = new Set(); +for (const ext of res.extensions) for (const name of ext.tools.keys()) tools.add(name); +process.stdout.write(JSON.stringify({ tools: [...tools] })); +`; + +/** Realpath of the `pi` executable, resolved from an absolute path or `PATH`. */ +function resolvePiBinPath(bin: string): string | null { + if (bin.includes(path.sep)) { + return existsSync(bin) ? realpathSync(bin) : null; + } + const dirs = (process.env.PATH ?? "").split(path.delimiter); + for (const dir of dirs) { + const candidate = path.join(dir, bin); + if (existsSync(candidate)) return realpathSync(candidate); + } + return null; +} + +/** Ascends from a directory looking for a package that ships the loader file. */ +function ascendForLoader(startDir: string): string | null { + let dir = startDir; + for (let depth = 0; depth < 6; depth += 1) { + const loader = path.join(dir, LOADER_SUBPATH); + if (existsSync(loader)) return loader; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +/** Finds the loader inside a pnpm virtual store directory, newest version first. */ +function scanPnpmStore(pnpmDir: string): string | null { + const dirs = readdirSync(pnpmDir) + .filter((name) => name.startsWith(PNPM_PREFIX)) + .sort() + .reverse(); + for (const name of dirs) { + const loader = path.join(pnpmDir, name, PKG_SUBPATH, LOADER_SUBPATH); + if (existsSync(loader)) return loader; + } + return null; +} + +/** Ascends from a directory searching each `node_modules/.pnpm` store for Pi. */ +function findInPnpmStore(startDir: string): string | null { + let dir = startDir; + for (let depth = 0; depth < 8; depth += 1) { + const pnpmDir = path.join(dir, "node_modules", ".pnpm"); + if (existsSync(pnpmDir)) { + const hit = scanPnpmStore(pnpmDir); + if (hit) return hit; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +/** + * Locates Pi's extension loader across deployment layouts: an explicit + * `PI_EXTENSION_LOADER` override, the `pi` binary's own package (npm global or + * local installs), or the workspace pnpm store (the monorepo dev setup, where + * the on-`PATH` `pi` is a standalone binary with no reachable package files). + */ +function resolveLoaderPath(): string | null { + const override = process.env.PI_EXTENSION_LOADER; + if (override && existsSync(override)) return override; + + const binPath = resolvePiBinPath(PI_BIN); + const fromBin = binPath ? ascendForLoader(path.dirname(binPath)) : null; + if (fromBin) return fromBin; + + return findInPnpmStore(path.dirname(fileURLToPath(import.meta.url))); +} + +/** Runs the probe subprocess, resolving its raw stdout or `null` on failure. */ +function execProbe(scriptPath: string, cfg: string): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, [scriptPath, cfg], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve(null); + }, PROBE_TIMEOUT_MS); + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("error", () => { + clearTimeout(timer); + resolve(null); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (code === 0) return resolve(stdout); + if (stderr) + console.error(`[extensions] tool probe failed: ${stderr.trim()}`); + resolve(null); + }); + }); +} + +/** Parses the probe's JSON stdout into a string tool list (or `null`). */ +function parseProbeOutput(out: string): string[] | null { + try { + const data: unknown = JSON.parse(out); + const tools = (data as { tools?: unknown }).tools; + if (!Array.isArray(tools)) return null; + return tools.filter((tool): tool is string => typeof tool === "string"); + } catch { + return null; + } +} + +/** Materializes the probe script to a temp file and runs it against `paths`. */ +async function runProbe( + loaderPath: string, + paths: string[], + cwd: string, +): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "ext-probe-")); + const scriptPath = path.join(dir, "probe.mjs"); + try { + await writeFile(scriptPath, PROBE_SOURCE); + const cfg = Buffer.from( + JSON.stringify({ loaderPath, paths, cwd }), + ).toString("base64"); + const out = await execProbe(scriptPath, cfg); + return out === null ? null : parseProbeOutput(out); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** + * Enumerates the tool names a set of extension files registers, by loading them + * in an isolated Node subprocess through Pi's own extension loader. Returns the + * deduped names, or `null` when the probe can't run (no `pi` on `PATH`, an + * unexpected package layout, a load error, or a timeout) so callers can fall + * back to a safe default. + */ +export async function probeExtensionTools( + extensionPaths: string[], + cwd: string, +): Promise { + if (extensionPaths.length === 0) return null; + const loaderPath = resolveLoaderPath(); + if (!loaderPath) return null; + return runProbe(loaderPath, extensionPaths, cwd); +} diff --git a/apps/server/src/extensions/selection.test.ts b/apps/server/src/extensions/selection.test.ts new file mode 100644 index 00000000..cdd181c4 --- /dev/null +++ b/apps/server/src/extensions/selection.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { + BundleExtensionPolicy, + ExtensionSelection, +} from "@tangent/shared/contracts.ts"; + +import { isLockedPolicy, resolveExtensionSelection } from "./selection.ts"; + +type Case = { + name: string; + policy: BundleExtensionPolicy | undefined; + selection: ExtensionSelection | undefined; + expected: "all" | string[]; +}; + +const cases: Case[] = [ + // No bundle policy: the session's own value (absent means none). + { + name: "no policy, no selection", + policy: undefined, + selection: undefined, + expected: [], + }, + { + name: "no policy, all", + policy: undefined, + selection: "all", + expected: "all", + }, + { + name: "no policy, none", + policy: undefined, + selection: "none", + expected: [], + }, + { + name: "no policy, list", + policy: undefined, + selection: ["a", "b"], + expected: ["a", "b"], + }, + + // Bundle "all": absent inherits all; any selection overrides fully. + { + name: "all, no selection", + policy: "all", + selection: undefined, + expected: "all", + }, + { name: "all, all", policy: "all", selection: "all", expected: "all" }, + { name: "all, none", policy: "all", selection: "none", expected: [] }, + { name: "all, list", policy: "all", selection: ["a"], expected: ["a"] }, + + // Bundle list: absent inherits; a list extends; none clears; all widens. + { + name: "list, no selection", + policy: ["a", "b"], + selection: undefined, + expected: ["a", "b"], + }, + { + name: "list, extend", + policy: ["a", "b"], + selection: ["c"], + expected: ["a", "b", "c"], + }, + { + name: "list, overlap dedupes", + policy: ["a", "b"], + selection: ["b", "c"], + expected: ["a", "b", "c"], + }, + { name: "list, none", policy: ["a", "b"], selection: "none", expected: [] }, + { name: "list, all", policy: ["a", "b"], selection: "all", expected: "all" }, + + // Bundle { only }: always exactly those ids, ignoring the session. + { + name: "only, no selection", + policy: { only: ["a"] }, + selection: undefined, + expected: ["a"], + }, + { + name: "only, ignores selection", + policy: { only: ["a"] }, + selection: ["b"], + expected: ["a"], + }, + { + name: "only empty locks to none", + policy: { only: [] }, + selection: "all", + expected: [], + }, +]; + +for (const testCase of cases) { + test(`resolveExtensionSelection: ${testCase.name}`, () => { + assert.deepEqual( + resolveExtensionSelection(testCase.policy, testCase.selection), + testCase.expected, + ); + }); +} + +test("isLockedPolicy only matches the { only } form", () => { + assert.equal(isLockedPolicy({ only: ["a"] }), true); + assert.equal(isLockedPolicy({ only: [] }), true); + assert.equal(isLockedPolicy("all"), false); + assert.equal(isLockedPolicy(["a"]), false); + assert.equal(isLockedPolicy(undefined), false); +}); diff --git a/apps/server/src/extensions/selection.ts b/apps/server/src/extensions/selection.ts new file mode 100644 index 00000000..5b4f4d8b --- /dev/null +++ b/apps/server/src/extensions/selection.ts @@ -0,0 +1,64 @@ +import type { + BundleExtensionPolicy, + ExtensionSelection, +} from "@tangent/shared/contracts.ts"; + +/** Whether a bundle policy is the locked `{ only }` form. */ +export function isLockedPolicy( + policy: BundleExtensionPolicy | undefined, +): policy is { only: string[] } { + return ( + typeof policy === "object" && !Array.isArray(policy) && "only" in policy + ); +} + +/** Dedupes while preserving first-seen order. */ +function unique(ids: string[]): string[] { + return [...new Set(ids)]; +} + +/** + * Resolves the effective global-extension set for a spawn from the two opt-in + * layers: a bundle's {@link BundleExtensionPolicy} and a session's + * {@link ExtensionSelection}. Returns `"all"` (every installed extension) or an + * explicit, deduped list of ids. Pure, so the rules are unit-testable in one + * place; the caller expands `"all"` to the currently installed ids. + * + * Precedence (see the `BundleExtensionPolicy` / `ExtensionSelection` contracts): + * - `{ only }`: always exactly those ids, whatever the session asks (the API + * rejects a defined session selection against a locked bundle). + * - No bundle policy: the session's own value (absent means none). + * - Bundle `"all"`: the session overrides fully; absent inherits all. + * - Bundle list: a session list extends it, `"none"` clears it, `"all"` widens + * to all, and absent inherits the bundle ids. + */ +export function resolveExtensionSelection( + policy: BundleExtensionPolicy | undefined, + selection: ExtensionSelection | undefined, +): "all" | string[] { + if (isLockedPolicy(policy)) return unique(policy.only); + if (selection === "all") return "all"; + if (selection === "none") return []; + return resolveListSelection(policy, selection); +} + +/** + * The non-locked path, where the session selection is a list or absent: no + * bundle policy means the session's own value (absent = none); bundle `"all"` + * is overridden by any list and inherited when absent; a bundle list is + * extended by a session list and inherited when absent. + */ +function resolveListSelection( + policy: "all" | string[] | undefined, + selection: string[] | undefined, +): "all" | string[] { + if (policy === undefined) { + return selection === undefined ? [] : unique(selection); + } + if (policy === "all") { + return selection === undefined ? "all" : unique(selection); + } + return selection === undefined + ? unique(policy) + : unique([...policy, ...selection]); +} diff --git a/apps/server/src/extensions/sessionExtensions.ts b/apps/server/src/extensions/sessionExtensions.ts new file mode 100644 index 00000000..2b173172 --- /dev/null +++ b/apps/server/src/extensions/sessionExtensions.ts @@ -0,0 +1,48 @@ +import type { + BundleExtensionPolicy, + ExtensionSelection, +} from "@tangent/shared/contracts.ts"; + +import type { GlobalExtensionService } from "./globalExtensionService.ts"; +import { resolveExtensionSelection } from "./selection.ts"; +import type { ExtensionSpawnPlan } from "./types.ts"; + +/** Synchronous read of a session's persisted global-extension selection. */ +export interface SessionExtensionSelections { + extensionsOf(sessionId: string): ExtensionSelection | undefined; +} + +/** + * The spawn-time projection the agent manager asks for each agent: the resolved + * {@link ExtensionSpawnPlan} for a session, given the session's bundle policy. + * Injected so the manager needs neither the store nor the service directly. + */ +export interface SessionExtensions { + spawnPlan( + sessionId: string, + policy: BundleExtensionPolicy | undefined, + ): ExtensionSpawnPlan; +} + +/** + * Wires a session's persisted selection and the installed-extension service + * into a {@link SessionExtensions}: it layers the bundle policy over the session + * selection ({@link resolveExtensionSelection}), expands `"all"` to the + * currently installed ids, and returns that set's spawn plan — recomputed every + * spawn, so a new install or an opt-in change takes effect on the next one. + */ +export function createSessionExtensions( + service: GlobalExtensionService, + selections: SessionExtensionSelections, +): SessionExtensions { + return { + spawnPlan(sessionId, policy) { + const resolved = resolveExtensionSelection( + policy, + selections.extensionsOf(sessionId), + ); + const ids = resolved === "all" ? service.readyIds() : resolved; + return service.spawnPlan(ids); + }, + }; +} diff --git a/apps/server/src/extensions/types.ts b/apps/server/src/extensions/types.ts new file mode 100644 index 00000000..25c3be88 --- /dev/null +++ b/apps/server/src/extensions/types.ts @@ -0,0 +1,56 @@ +import type { MarketplaceListing } from "@tangent/shared/contracts.ts"; + +export type { MarketplaceListing }; + +/** Per-install context passed to a {@link ExtensionMarketplace.install}. */ +export interface InstallContext { + /** + * For `file` sources: the absolute path to the preserved upload (under the + * install's `source/`). Omitted for sources that fetch their own bytes + * (npm/git). + */ + sourcePath?: string; +} + +/** + * A pluggable install source for global extensions. The registry keys one by + * its {@link ExtensionMarketplace.id} (`npm`, `git`, `file`, or a future + * proprietary source), and a `":"` source string selects it. Adding a + * new source is one class plus one `register()` call — nothing else in the + * install/spawn path changes. + */ +export interface ExtensionMarketplace { + /** Stable source id used as the `":"` prefix. */ + readonly id: string; + /** + * Installs `ref` into `pkgDir` (an empty, install-owned directory) and returns + * the absolute package root — the directory holding the package's + * `package.json`, or the directory a single-file extension was copied into. + */ + install(ref: string, pkgDir: string, ctx: InstallContext): Promise; + /** Optional discovery, when the source exposes a searchable catalog. */ + search?(query: string): Promise; +} + +/** A parsed `":"` source: the resolved marketplace and ref. */ +export interface ParsedSource { + marketplace: ExtensionMarketplace; + ref: string; +} + +/** + * The resource paths and tool allowlist a set of installed extensions + * contributes to a Pi spawn. Mirrors the shape {@link + * import("../memory/resolver.ts").MemorySpawnPlan} contributes, so the agent + * manager folds both in the same way. + */ +export interface ExtensionSpawnPlan { + /** Absolute `--extension` file paths. */ + extensionPaths: string[]; + /** Absolute `--skill` directory (or file) paths. */ + skillPaths: string[]; + /** Absolute `--prompt-template` file paths. */ + promptPaths: string[]; + /** Tool names unioned into the agent's `--tools` allowlist. */ + allowlistTools: string[]; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d8e51757..7ca20e3d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -18,6 +18,10 @@ import { ParticipantRegistry } from "./conversation/participantRegistry.ts"; import { ParticipantService } from "./conversation/participantService.ts"; import { ReactorRegistry } from "./conversation/reactorRegistry.ts"; import { ResourceCatalog } from "./conversation/resourceCatalog.ts"; +import { FileExtensionStore } from "./extensions/fileExtensionStore.ts"; +import { GlobalExtensionService } from "./extensions/globalExtensionService.ts"; +import { createMarketplaceRegistry } from "./extensions/marketplaces/registry.ts"; +import { createSessionExtensions } from "./extensions/sessionExtensions.ts"; import { ExternalSubagentGateway } from "./external/externalSubagentGateway.ts"; import { RelayRegistry } from "./mcp/relayRegistry.ts"; import { createRelayReport } from "./mcp/relayReport.ts"; @@ -39,6 +43,7 @@ import { createAttachRouter } from "./routes/attach.ts"; import { createBundleEditorsRouter } from "./routes/bundleEditors.ts"; import { createConfigRouter } from "./routes/config.ts"; import { createEmbedRouter } from "./routes/embed.ts"; +import { createGlobalExtensionsRouter } from "./routes/globalExtensions.ts"; import { createGlobalMemoryRouter } from "./routes/globalMemory.ts"; import { createInternalAgentsRouter } from "./routes/internalAgents.ts"; import { createInternalEgressRouter } from "./routes/internalEgress.ts"; @@ -102,6 +107,15 @@ const store = new SqliteSessionStore(db, resourceStore); const sharedFolderStore = new SqliteSharedFolderStore(db); // Filesystem-backed marketplace of saved agent bundles. const agentBundleStore = new FileAgentBundleStore(); +// Global extension registry: Pi packages installed from a marketplace (npm, +// git, file) into the persistent volume and applied to sessions that opt in. +const extensionService = new GlobalExtensionService( + createMarketplaceRegistry(), + new FileExtensionStore(), +); +// Resolves each session's opted-in extensions (bundle policy overlaid by the +// session's selection) into the extra spawn flags for the agent manager. +const sessionExtensions = createSessionExtensions(extensionService, store); // Who besides the ADMIN_EMAILS admins may upload and delete those bundles. const bundleEditorStore = new SqliteBundleEditorStore(db); @@ -293,6 +307,7 @@ const pi = new PiAgentManager( runs, hostResourcePreamble, (sessionId) => store.selectorsOf(sessionId), + sessionExtensions, ); // Hosts sub-agents inside a connected remote environment over the `/remote-env` @@ -455,6 +470,9 @@ app.use( createAgentBundlesRouter(agentBundleStore, bundleEditorStore), ); app.use("/api/bundle-editors", createBundleEditorsRouter(bundleEditorStore)); +// Global extension registry: install from a marketplace or file, list, search, +// and uninstall. Applied to sessions that opt in (bundle policy + selection). +app.use("/api/extensions", createGlobalExtensionsRouter(extensionService)); // 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)); @@ -565,6 +583,21 @@ registerChatHandlers({ presence, }); +// Load the installed global extensions into memory and reinstall anything the +// volume is missing (a fresh pod on a reused data volume, or an interrupted +// write), so an install survives a pod restart. Never fatal: a failed reinstall +// is logged and the extension is kept as `error` for a later retry. +await extensionService + .reconcile() + .then((reinstalled) => { + if (reinstalled > 0) { + console.log(`[extensions] reinstalled ${reinstalled} extension(s)`); + } + }) + .catch((err: unknown) => { + console.error("[extensions] startup reconcile failed", err); + }); + httpServer.listen(PORT, () => { console.log(`[server] listening on http://localhost:${PORT}`); }); diff --git a/apps/server/src/pi/agentConfig.ts b/apps/server/src/pi/agentConfig.ts index 67b222a7..7c6ba1e1 100644 --- a/apps/server/src/pi/agentConfig.ts +++ b/apps/server/src/pi/agentConfig.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { + type BundleExtensionPolicy, type MemoryBinding, THINKING_LEVELS, type ThinkingLevel, @@ -169,6 +170,12 @@ export interface ResolvedSessionConfig { welcomeMessage?: string; /** The bundle's default memory binding, if it declares one. */ memory?: MemoryBinding; + /** + * The bundle's global-extension opt-in policy, if it declares one (the bundle + * layer overlaid by a session's selection at spawn). Re-resolved on revive + * from the installed manifest. + */ + globalExtensions?: BundleExtensionPolicy; } /** Request Prime makes to spawn a sub-agent. Inline fields override templates. */ diff --git a/apps/server/src/pi/config/bundleLoader.ts b/apps/server/src/pi/config/bundleLoader.ts index e5a5d863..57a49833 100644 --- a/apps/server/src/pi/config/bundleLoader.ts +++ b/apps/server/src/pi/config/bundleLoader.ts @@ -363,6 +363,9 @@ export function resolveSessionConfig( } : {}), ...(manifest.memoryBinding ? { memory: manifest.memoryBinding } : {}), + ...(manifest.globalExtensions !== undefined + ? { globalExtensions: manifest.globalExtensions } + : {}), ...resolveSectionPaths(entries, manifest, installRoot), }; } diff --git a/apps/server/src/pi/config/manifest.ts b/apps/server/src/pi/config/manifest.ts index c9503d2c..3c7d583a 100644 --- a/apps/server/src/pi/config/manifest.ts +++ b/apps/server/src/pi/config/manifest.ts @@ -61,6 +61,23 @@ const storeRoot = safePath.refine((value) => !hasQmdSegment(value), { message: "must not contain a .qmd segment", }); +/** A global-extension id slug (matches the install registry's id rule). */ +const extensionId = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, { + message: "must be a slug matching ^[a-z0-9][a-z0-9-]*$", +}); + +/** + * Validator for a bundle's `globalExtensions` opt-in policy: `"all"`, a list of + * extension ids the session may extend, or `{ only: [...] }` the session may not + * override. Mirrors the shared {@link + * import("@tangent/shared/contracts.ts").BundleExtensionPolicy} type. + */ +const globalExtensionPolicySchema = z.union([ + z.literal("all"), + z.array(extensionId), + z.object({ only: z.array(extensionId) }), +]); + /** Tunable knobs for a `pi-memory` {@link MemoryBinding}. */ const piMemoryConfigSchema = z.object({ exitSummary: z.boolean().optional(), @@ -136,6 +153,7 @@ const manifestSchema = z.object({ memory: z.array(safePath).optional(), memoryBinding: memoryBindingSchema.optional(), extensions: z.array(safePath).optional(), + globalExtensions: globalExtensionPolicySchema.optional(), software: z.record(z.string().min(1), z.array(z.string().min(1))).optional(), ui: z .object({ diff --git a/apps/server/src/pi/piAgentManager.test.ts b/apps/server/src/pi/piAgentManager.test.ts index 0e3e5943..edb12a4f 100644 --- a/apps/server/src/pi/piAgentManager.test.ts +++ b/apps/server/src/pi/piAgentManager.test.ts @@ -166,6 +166,7 @@ function makeManager(options?: { runs, undefined, () => options?.selectors ?? [], + undefined, fakeSpawn, ); return { pi, spawns, rosterUpdates, events, runs, runStore }; diff --git a/apps/server/src/pi/piAgentManager.ts b/apps/server/src/pi/piAgentManager.ts index acdbe2c6..92ccb3db 100644 --- a/apps/server/src/pi/piAgentManager.ts +++ b/apps/server/src/pi/piAgentManager.ts @@ -29,6 +29,8 @@ import { UPLOADS_DIRNAME, } from "../config.ts"; import { piCredential } from "../connectors/credentials.ts"; +import type { SessionExtensions } from "../extensions/sessionExtensions.ts"; +import type { ExtensionSpawnPlan } from "../extensions/types.ts"; import type { MemoryResolver, MemorySpawnPlan, @@ -289,45 +291,33 @@ const EMPTY_MEMORY_PLAN: MemorySpawnPlan = { deniedTools: [], }; -/** - * The deduped `--tools` allowlist. Memory tools are granted at spawn from the - * resolved memory (not baked into the static allowlist), so a pi-memory-bound - * session gets that backend's tools in addition to the always-on SimpleMD tools. - * Memory tools the role may not use are dropped even when a spawn request or - * template lists them, and the recall tools are present only when `recall` - * grants them. - */ -function spawnToolList( - config: AgentConfig, - memory: MemorySpawnPlan, - recall: boolean, -): string[] { - const denied = new Set(memory.deniedTools); - const tools = new Set([ - ...config.tools.filter((name) => !denied.has(name)), - ...memory.allowlistTools, - ...(recall ? SESSION_RECALL_TOOLS : []), - ]); - if (!recall) { - for (const name of SESSION_RECALL_TOOLS) tools.delete(name); - } - return [...tools]; -} +/** A no-op extension plan, used when a session opts into no global extensions. */ +const EMPTY_EXTENSION_PLAN: ExtensionSpawnPlan = { + extensionPaths: [], + skillPaths: [], + promptPaths: [], + allowlistTools: [], +}; /** * The repeated resource flags for a spawn: the always-on built-in extensions, * the bundle's skills/workflows/extensions (shared by every agent in the - * session), and the resolved memory's extra extensions (e.g. the pi-memory pkg). + * session), the resolved memory's extra extensions (e.g. the pi-memory pkg), and + * the session's opted-in global extensions (skills/prompts/extensions). */ function resourceFlagArgs( extras: SpawnExtras, memory: MemorySpawnPlan, + extensions: ExtensionSpawnPlan, ): string[] { const flagged: Array<[string, string[]]> = [ ["--skill", extras.skillPaths], ["--prompt-template", extras.workflowPaths], ["--extension", extras.extensionPaths], ["--extension", memory.extensionPaths], + ["--extension", extensions.extensionPaths], + ["--skill", extensions.skillPaths], + ["--prompt-template", extensions.promptPaths], ]; const args: string[] = []; for (const extension of BUILTIN_EXTENSIONS) @@ -338,6 +328,33 @@ function resourceFlagArgs( return args; } +/** + * The deduped `--tools` allowlist. Memory tools are granted at spawn from the + * resolved memory (not baked into the static allowlist), so a pi-memory-bound + * session gets that backend's tools in addition to the always-on SimpleMD tools. + * Global-extension tools are added the same way. Memory tools the role may not + * use are dropped even when a spawn request or template lists them, and the + * recall tools are present only when `recall` grants them. + */ +function spawnToolList( + config: AgentConfig, + memory: MemorySpawnPlan, + extensions: ExtensionSpawnPlan, + recall: boolean, +): string[] { + const denied = new Set(memory.deniedTools); + const tools = new Set([ + ...config.tools.filter((name) => !denied.has(name)), + ...memory.allowlistTools, + ...extensions.allowlistTools, + ...(recall ? SESSION_RECALL_TOOLS : []), + ]); + if (!recall) { + for (const name of SESSION_RECALL_TOOLS) tools.delete(name); + } + return [...tools]; +} + /** * Builds the `pi --mode rpc` CLI args for an agent process. The appended system * prompt is passed as a file path (not inline text): it embeds the unbounded @@ -350,11 +367,13 @@ function buildPiArgs( extras: SpawnExtras, appendPromptSource: string | undefined, memoryPlan: MemorySpawnPlan | undefined, + extensionPlan: ExtensionSpawnPlan | undefined, grant: SpawnGrant, ): string[] { const { provider, model, thinking } = resolveModelArgs(config); const memory = memoryPlan ?? EMPTY_MEMORY_PLAN; - const tools = spawnToolList(config, memory, grant.recall); + const extensions = extensionPlan ?? EMPTY_EXTENSION_PLAN; + const tools = spawnToolList(config, memory, extensions, grant.recall); const args = [ "--mode", "rpc", @@ -378,7 +397,7 @@ function buildPiArgs( if (appendPromptSource) args.push("--append-system-prompt", appendPromptSource); - args.push(...resourceFlagArgs(extras, memory)); + args.push(...resourceFlagArgs(extras, memory, extensions)); if (grant.recall) args.push("--extension", SESSION_RECALL_EXTENSION); return args; } @@ -669,10 +688,12 @@ function logSpawn( appendPromptFile: string | null, appendPromptBytes: number, memoryPlan: MemorySpawnPlan | undefined, + extensionPlan: ExtensionSpawnPlan | undefined, ): void { const { provider, model, thinking } = resolveModelArgs(config); const memory = memoryPlan ?? EMPTY_MEMORY_PLAN; - const tools = spawnToolList(config, memory, grant.recall); + const extensions = extensionPlan ?? EMPTY_EXTENSION_PLAN; + const tools = spawnToolList(config, memory, extensions, grant.recall); console.log( `[pi:${grant.sessionId}:${descriptor.agentId}] spawning`, JSON.stringify({ @@ -685,9 +706,13 @@ function logSpawn( proxyUrl: PI_PROXY_URL, hasProxyApiKey: Boolean(process.env.PI_PROXY_API_KEY), tools: tools.join(","), - skills: extras.skillPaths, - workflows: extras.workflowPaths, - extensions: [...extras.extensionPaths, ...memory.extensionPaths], + skills: [...extras.skillPaths, ...extensions.skillPaths], + workflows: [...extras.workflowPaths, ...extensions.promptPaths], + extensions: [ + ...extras.extensionPaths, + ...memory.extensionPaths, + ...extensions.extensionPaths, + ], memoryEnv: Object.keys(memory.env), appendPromptFile, appendPromptBytes, @@ -811,6 +836,13 @@ export class PiAgentManager { * tools. Optional so a bare manager (e.g. a test) spawns with recall off. */ private readonly selectorsOf?: SessionSelectors; + /** + * Resolves each session's opted-in global extensions into the extra + * `--extension` / `--skill` / `--prompt-template` paths and `--tools` entries + * folded into every agent's spawn. Optional so a bare manager (e.g. a test) + * spawns with no global extensions. + */ + private readonly sessionExtensions?: SessionExtensions; /** * Process launcher, injectable so tests can supply a fake child without * spawning a real `pi` binary. Defaults to Node's {@link spawn}. @@ -831,6 +863,7 @@ export class PiAgentManager { runs: RunRegistry, hostResources?: HostResourcePreamble, selectorsOf?: SessionSelectors, + sessionExtensions?: SessionExtensions, spawnProcess: typeof spawn = spawn, ) { this.spawnProcess = spawnProcess; @@ -839,6 +872,7 @@ export class PiAgentManager { this.runs = runs; this.hostResources = hostResources; this.selectorsOf = selectorsOf; + this.sessionExtensions = sessionExtensions; } /** @@ -1413,6 +1447,21 @@ export class PiAgentManager { return { sessionId, recall: role === "prime" && tagged }; } + /** + * The session's opted-in global-extension spawn plan (resolved from its bundle + * policy and persisted selection), or `undefined` when none is wired. Computed + * per spawn so a new install or opt-in change applies on the next spawn. + */ + private extensionPlanFor( + sessionId: string, + session: SessionAgents, + ): ExtensionSpawnPlan | undefined { + return this.sessionExtensions?.spawnPlan( + sessionId, + session.config?.globalExtensions, + ); + } + /** * Launches the raw Pi child, converting a synchronous spawn failure (e.g. the * kernel's `E2BIG` when argv/env exceed its limit) into a relayed `error` @@ -1430,10 +1479,18 @@ export class PiAgentManager { ): ChildProcessWithoutNullStreams | null { const { sessionId } = grant; const memoryPlan = session.resolvedMemory?.spawnPlan(descriptor.role); + const extensionPlan = this.extensionPlanFor(sessionId, session); try { return this.spawnProcess( PI_BIN, - buildPiArgs(config, extras, appendPromptSource, memoryPlan, grant), + buildPiArgs( + config, + extras, + appendPromptSource, + memoryPlan, + extensionPlan, + grant, + ), { cwd: session.rootPath, env: spawnEnv( @@ -1490,6 +1547,7 @@ export class PiAgentManager { appendText ? promptFilePath(sessionId, descriptor.agentId) : null, Buffer.byteLength(appendText), session.resolvedMemory?.spawnPlan(descriptor.role), + this.extensionPlanFor(sessionId, session), ); const child = this.trySpawnChild( diff --git a/apps/server/src/routes/globalExtensions.ts b/apps/server/src/routes/globalExtensions.ts new file mode 100644 index 00000000..4fc45293 --- /dev/null +++ b/apps/server/src/routes/globalExtensions.ts @@ -0,0 +1,240 @@ +import type { + ListGlobalExtensionsResponse, + ListMarketplacesResponse, + SearchMarketplaceResponse, +} from "@tangent/shared/contracts.ts"; +import { type Request, type Response, Router } from "express"; +import { z } from "zod"; + +import { + ExtensionConflictError, + ExtensionValidationError, + type GlobalExtensionService, + type InstallOptions, +} from "../extensions/globalExtensionService.ts"; +import { requireAdmin } from "../middleware/requireRole.ts"; +import { getValidated, validate } from "../middleware/validate.ts"; +import { bundleUpload } from "./bundleUpload.ts"; + +/** `:id` route param for the single-extension routes. */ +const idParamsSchema = z.object({ id: z.string() }); +type IdParams = z.infer; + +/** `:id` route param for the marketplace search route. */ +const marketplaceParamsSchema = z.object({ id: z.string() }); +type MarketplaceParams = z.infer; + +/** `?q=` search query. */ +const searchQuerySchema = z.object({ q: z.string().optional() }); +type SearchQuery = z.infer; + +/** JSON install body (npm/git sources). */ +const installBodySchema = z.object({ + source: z.string().min(1), + id: z.string().optional(), + tools: z.array(z.string()).optional(), + replace: z.boolean().optional(), +}); + +/** PATCH body: update the tool allowlist and/or display name. */ +const patchBodySchema = z + .object({ + tools: z.array(z.string()).optional(), + name: z.string().min(1).optional(), + }) + .refine((body) => body.tools !== undefined || body.name !== undefined, { + message: "provide `tools` or `name`", + }); +type PatchBody = z.infer; + +/** Parses a multipart `tools` field: a JSON array or a comma-separated list. */ +function parseToolsField(raw: unknown): string[] | undefined { + if (typeof raw !== "string" || raw.trim() === "") return undefined; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter((tool): tool is string => typeof tool === "string"); + } + } catch { + // Not JSON — fall through to the comma-separated form. + } + return raw + .split(",") + .map((tool) => tool.trim()) + .filter(Boolean); +} + +/** Maps an install error to its HTTP status, defaulting to 400. */ +function installErrorStatus(err: unknown): number { + if (err instanceof ExtensionConflictError) return 409; + if (err instanceof ExtensionValidationError) return 400; + return 400; +} + +async function handleList( + service: GlobalExtensionService, + res: Response, +): Promise { + const response: ListGlobalExtensionsResponse = { + extensions: service.list(), + }; + res.json(response); +} + +function handleMarketplaces( + service: GlobalExtensionService, + res: Response, +): void { + const response: ListMarketplacesResponse = { + marketplaces: service.marketplaces(), + }; + res.json(response); +} + +async function handleSearch( + service: GlobalExtensionService, + id: string, + query: SearchQuery, + res: Response, +): Promise { + try { + const listings = await service.search(id, query.q ?? ""); + if (listings === undefined) { + res.status(404).json({ error: `Marketplace "${id}" has no search` }); + return; + } + const response: SearchMarketplaceResponse = { listings }; + res.json(response); + } catch (err) { + res.status(502).json({ error: (err as Error).message }); + } +} + +async function handleInstall( + service: GlobalExtensionService, + req: Request, + res: Response, +): Promise { + try { + const meta = req.file + ? await service.installFromUpload( + req.file.originalname, + req.file.buffer, + { + id: typeof req.body.id === "string" ? req.body.id : undefined, + tools: parseToolsField(req.body.tools), + replace: req.body.replace === "true" || req.body.replace === true, + }, + ) + : await installFromJson(service, req.body); + res.status(201).json({ extension: meta }); + } catch (err) { + res.status(installErrorStatus(err)).json({ error: (err as Error).message }); + } +} + +/** Validates and runs a JSON (npm/git) install from the request body. */ +async function installFromJson( + service: GlobalExtensionService, + body: unknown, +): Promise>> { + const parsed = installBodySchema.safeParse(body); + if (!parsed.success) { + throw new ExtensionValidationError( + "Missing or invalid install source (expected a file upload or `source`)", + ); + } + const options: InstallOptions = { + id: parsed.data.id, + tools: parsed.data.tools, + replace: parsed.data.replace, + }; + return service.installFromSource(parsed.data.source, options); +} + +async function handlePatch( + service: GlobalExtensionService, + id: string, + body: PatchBody, + res: Response, +): Promise { + const meta = await service.update(id, body); + if (!meta) { + res.status(404).json({ error: "Extension not found" }); + return; + } + res.json({ extension: meta }); +} + +async function handleDelete( + service: GlobalExtensionService, + id: string, + res: Response, +): Promise { + const deleted = await service.uninstall(id); + if (!deleted) { + res.status(404).json({ error: "Extension not found" }); + return; + } + res.status(204).end(); +} + +/** + * Routes for the global extension registry. Reads are open; writes + * (install/patch/uninstall) are admin-gated like the bundle marketplace. + */ +export function createGlobalExtensionsRouter( + service: GlobalExtensionService, +): Router { + const router = Router(); + + router.get("/", (_req: Request, res: Response) => handleList(service, res)); + + router.get("/marketplaces", (_req: Request, res: Response) => + handleMarketplaces(service, res), + ); + + router.get( + "/marketplaces/:id/search", + validate({ params: marketplaceParamsSchema, query: searchQuerySchema }), + (req: Request, res: Response) => { + const { params, query } = getValidated< + unknown, + MarketplaceParams, + SearchQuery + >(req); + return handleSearch(service, params.id, query, res); + }, + ); + + router.post( + "/", + requireAdmin(), + bundleUpload.single("file"), + (req: Request, res: Response) => handleInstall(service, req, res), + ); + + router.patch( + "/:id", + requireAdmin(), + validate({ params: idParamsSchema, body: patchBodySchema }), + (req: Request, res: Response) => { + const { params, body } = getValidated(req); + return handlePatch(service, params.id, body, res); + }, + ); + + router.delete( + "/:id", + requireAdmin(), + validate({ params: idParamsSchema }), + (req: Request, res: Response) => + handleDelete( + service, + getValidated(req).params.id, + res, + ), + ); + + return router; +} diff --git a/apps/server/src/routes/sessions/handlers.ts b/apps/server/src/routes/sessions/handlers.ts index 0c2fa88e..a239bd7e 100644 --- a/apps/server/src/routes/sessions/handlers.ts +++ b/apps/server/src/routes/sessions/handlers.ts @@ -4,6 +4,8 @@ import path from "node:path"; import type { Attachment, + BundleExtensionPolicy, + ExtensionSelection, HostResourceInput, MemoryBinding, Session, @@ -25,9 +27,14 @@ import { import { applyResourceInput } from "../../conversation/hostResources.ts"; import { orchestratorConversationFor } from "../../conversation/participantRegistry.ts"; import type { ResourceCatalog } from "../../conversation/resourceCatalog.ts"; +import { isLockedPolicy } from "../../extensions/selection.ts"; import { mountedFolderPaths, settleBinding } from "../../memory/binding.ts"; import { removeSessionQmdState } from "../../memory/qmdState.ts"; -import { installBundle } from "../../pi/config/bundleLoader.ts"; +import type { ResolvedSessionConfig } from "../../pi/agentConfig.ts"; +import { + installBundle, + loadInstalledConfig, +} from "../../pi/config/bundleLoader.ts"; import type { HostResourcePreamble } from "../../pi/hostResourcePreamble.ts"; import type { MemoryManager } from "../../pi/memory.ts"; import type { PiAgentManager } from "../../pi/piAgentManager.ts"; @@ -40,6 +47,7 @@ import { injectPageBridge } from "./pageBridge.ts"; import type { CreateSessionInput, SessionParams, + SetSessionExtensionsInput, UpdateSessionInput, } from "./schemas.ts"; import { @@ -204,6 +212,31 @@ async function storeSessionMemory( if (binding) await deps.store.setSessionMemory(sessionId, binding); } +/** Persists a session's memory binding and global-extension override at create. */ +async function applySessionOverrides( + deps: SessionCreateDeps, + sessionId: string, + rootPath: string, + config: ResolvedSessionConfig, + overrides: { + memory: MemoryBinding | undefined; + extensions: ExtensionSelection | undefined; + }, +): Promise { + await storeSessionMemory( + deps, + sessionId, + rootPath, + overrides.memory ?? config.memory, + ); + await applyBundleExtensionOverride( + deps.store, + sessionId, + config.globalExtensions, + overrides.extensions, + ); +} + /** * Pre-seeds Prime's first message so the bundle's agent "speaks first" (e.g. * renders a welcome card). It replays via `chat:history` on join and renders any @@ -246,18 +279,16 @@ async function createSessionFromBundle( resources: HostResourceInput[], env: Record | undefined, memoryOverride: MemoryBinding | undefined, + extensionsOverride: ExtensionSelection | undefined, res: Response, ): Promise { const { store, pi, triggerEngine } = deps; try { const { manifest, config } = await installBundle(zipBuffer, rootPath); - const meta: SessionConfigMeta = { - id: manifest.id, - name: manifest.name, - version: manifest.version, - icon: manifest.icon, - }; - const withConfig = await store.attachConfig(sessionId, meta); + const withConfig = await store.attachConfig( + sessionId, + configMetaOf(manifest), + ); const primaryConversationId = await orchestratorConversationFor( store, @@ -271,12 +302,10 @@ async function createSessionFromBundle( ); await seedResources(deps, sessionId, rootPath, resources, user); - await storeSessionMemory( - deps, - sessionId, - rootPath, - memoryOverride ?? config.memory, - ); + await applySessionOverrides(deps, sessionId, rootPath, config, { + memory: memoryOverride, + extensions: extensionsOverride, + }); // Armed only once the binding is stored: a firing spawns Prime, which // resolves memory exactly once. @@ -292,7 +321,8 @@ async function createSessionFromBundle( } catch (err) { await store.deleteSession(sessionId); removeSessionQmdState(sessionId); - res.status(400).json({ error: (err as Error).message }); + const status = err instanceof LockedExtensionsError ? 409 : 400; + res.status(status).json({ error: (err as Error).message }); } } @@ -343,10 +373,84 @@ export async function handleCreateSession( body.resources ?? [], body.env, body.memory, + body.extensions, res, ); } +/** The wire config metadata recorded for a session from its bundle manifest. */ +function configMetaOf(manifest: { + id: string; + name: string; + version: string; + icon?: string; +}): SessionConfigMeta { + return { + id: manifest.id, + name: manifest.name, + version: manifest.version, + icon: manifest.icon, + }; +} + +/** Raised when a session tries to override a bundle that locks its extensions. */ +class LockedExtensionsError extends Error {} + +/** + * Persists a session's global-extension override at create time. A bundle that + * locks its policy (`{ only }`) rejects any override; otherwise the session's + * selection (when supplied) is stored as-is to overlay the bundle at spawn. + */ +async function applyBundleExtensionOverride( + store: SessionStore, + sessionId: string, + policy: BundleExtensionPolicy | undefined, + override: ExtensionSelection | undefined, +): Promise { + if (override === undefined) return; + if (isLockedPolicy(policy)) { + throw new LockedExtensionsError( + "bundle locks global extensions; the session cannot override them", + ); + } + await store.setSessionExtensions(sessionId, override); +} + +/** Whether a session's installed bundle locks its global-extension policy. */ +function bundleLocksExtensions(rootPath: string): boolean { + return isLockedPolicy(loadInstalledConfig(rootPath)?.globalExtensions); +} + +/** + * Handles `PUT /api/sessions/:id/extensions`: replaces the session's + * global-extension selection (or resets it to inherit the bundle policy when + * `extensions` is `null`). Rejected with `409` when the session's installed + * bundle locks the policy. Takes effect on each agent's next spawn. + */ +export async function handleSetSessionExtensions( + store: SessionStore, + id: string, + input: SetSessionExtensionsInput, + res: Response, +): Promise { + const session = await store.getSession(id); + if (!session) { + res.status(404).json({ error: "Session not found" }); + return; + } + + const selection = input.extensions ?? undefined; + if (selection !== undefined && bundleLocksExtensions(session.rootPath)) { + res.status(409).json({ + error: "This session's bundle locks global extensions", + }); + return; + } + + await store.setSessionExtensions(id, selection); + res.json({ extensions: selection ?? null }); +} + /** * Maps multer's stored files to {@link Attachment} metadata. `filename` is the * (uniquified) on-disk name; `originalname` is what the user picked and is what diff --git a/apps/server/src/routes/sessions/index.ts b/apps/server/src/routes/sessions/index.ts index 1a5415b3..178440a4 100644 --- a/apps/server/src/routes/sessions/index.ts +++ b/apps/server/src/routes/sessions/index.ts @@ -22,6 +22,7 @@ import { handleGetSession, handleListSessions, handleMarkSessionViewed, + handleSetSessionExtensions, handleUpdateSession, handleUploadFiles, type SessionCreateDeps, @@ -32,11 +33,13 @@ import { registerResourceRoutes } from "./resources.ts"; import type { CreateSessionInput, SessionParams, + SetSessionExtensionsInput, UpdateSessionInput, } from "./schemas.ts"; import { createSessionSchema, sessionParamsSchema, + setSessionExtensionsSchema, updateSessionSchema, } from "./schemas.ts"; import { registerTriggerRoutes } from "./triggers.ts"; @@ -133,6 +136,18 @@ function registerSessionActivityRoutes( (req: Request, res: Response) => handleUploadFiles(store, req, res), ); + + router.put( + "/:id/extensions", + validate({ params: sessionParamsSchema, body: setSessionExtensionsSchema }), + (req: Request, res: Response) => { + const { params, body } = getValidated< + SetSessionExtensionsInput, + SessionParams + >(req); + return handleSetSessionExtensions(store, params.id, body, res); + }, + ); } export function createSessionsRouter( diff --git a/apps/server/src/routes/sessions/schemas.ts b/apps/server/src/routes/sessions/schemas.ts index 50b01067..4789cb28 100644 --- a/apps/server/src/routes/sessions/schemas.ts +++ b/apps/server/src/routes/sessions/schemas.ts @@ -77,6 +77,16 @@ export const hostResourceInputSchema = z.discriminatedUnion("kind", [ ]); export type HostResourceInputBody = z.infer; +/** A global-extension id slug (matches the install registry's id rule). */ +const extensionId = z.string().regex(/^[a-z0-9][a-z0-9-]*$/); + +/** A session's global-extension selection: `"all"`, `"none"`, or a list of ids. */ +export const extensionSelectionSchema = z.union([ + z.literal("all"), + z.literal("none"), + z.array(extensionId), +]); + export const createSessionSchema = z.object({ name: z.string().optional(), bundleId: z.string().min(1), @@ -84,9 +94,21 @@ export const createSessionSchema = z.object({ env: z.record(z.string(), z.string()).optional(), tags: z.array(z.string()).optional(), memory: memoryBindingSchema.optional(), + extensions: extensionSelectionSchema.optional(), }); export type CreateSessionInput = z.infer; +/** + * Body for `PUT /:id/extensions`: replace the session's global-extension + * selection, or `null` to reset it to inherit the bundle policy. + */ +export const setSessionExtensionsSchema = z.object({ + extensions: z.union([extensionSelectionSchema, z.null()]), +}); +export type SetSessionExtensionsInput = z.infer< + typeof setSessionExtensionsSchema +>; + /** `DELETE /:id/resources` query: the resource uri to remove. */ export const deleteResourceQuerySchema = z.object({ uri: z.string().min(1), diff --git a/apps/server/src/store/db/migrations/0027_secret_ink.sql b/apps/server/src/store/db/migrations/0027_secret_ink.sql new file mode 100644 index 00000000..28445a90 --- /dev/null +++ b/apps/server/src/store/db/migrations/0027_secret_ink.sql @@ -0,0 +1 @@ +ALTER TABLE `sessions` ADD `extensions` text; \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0027_snapshot.json b/apps/server/src/store/db/migrations/meta/0027_snapshot.json new file mode 100644 index 00000000..dc322b4c --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0027_snapshot.json @@ -0,0 +1,1056 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5f8ca18d-2a5f-4fc9-acd7-3f5ead73935c", + "prevId": "ef44c691-6163-4834-bb62-5c5dee6fbc3e", + "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 + }, + "memory": { + "name": "memory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extensions": { + "name": "extensions", + "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 6a53b1a6..dd1de015 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1793422241629, "tag": "0026_lazy_captain_cross", "breakpoints": true + }, + { + "idx": 27, + "version": "6", + "when": 1793605111476, + "tag": "0027_secret_ink", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index 514eafcb..06a06204 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -34,6 +34,9 @@ export const sessions = sqliteTable("sessions", { env: text("env"), /** Serialized `MemoryBinding` for the session's pluggable memory (server-only). */ memory: text("memory"), + /** Serialized `ExtensionSelection` for the session's global-extension opt-in + * (server-only). NULL means inherit the bundle's policy. */ + extensions: text("extensions"), /** Serialized `string[]` of selector tags (e.g. `projectId:`) used to * route selector-scoped remote hosts to this session. */ tags: text("tags"), diff --git a/apps/server/src/store/inMemorySessionStore.ts b/apps/server/src/store/inMemorySessionStore.ts index f289ceda..83607212 100644 --- a/apps/server/src/store/inMemorySessionStore.ts +++ b/apps/server/src/store/inMemorySessionStore.ts @@ -8,6 +8,7 @@ import { type ConnectorDescriptor, connectorFor, DEFAULT_SESSION_ACCESS_POLICY, + type ExtensionSelection, type MemoryBinding, type PinnedArtifact, type Session, @@ -88,6 +89,8 @@ export class InMemorySessionStore implements SessionStore { private readonly envs = new Map>(); /** Server-only per-session memory bindings, mirroring {@link envs}. */ private readonly memories = new Map(); + /** Server-only per-session global-extension selections (opt-in overlay). */ + private readonly extensionSelections = new Map(); private readonly messages = new Map(); private readonly artifacts = new Map(); private readonly views = new Map>(); @@ -147,6 +150,21 @@ export class InMemorySessionStore implements SessionStore { return this.sessions.get(id)?.tags ?? []; } + extensionsOf(id: string): ExtensionSelection | undefined { + return this.extensionSelections.get(id); + } + + async setSessionExtensions( + id: string, + extensions: ExtensionSelection | undefined, + ): Promise { + if (extensions === undefined) { + this.extensionSelections.delete(id); + } else { + this.extensionSelections.set(id, extensions); + } + } + async createSession(input: CreateSessionParams): Promise { const id = randomUUID(); const now = new Date().toISOString(); @@ -215,6 +233,7 @@ export class InMemorySessionStore implements SessionStore { this.messages.delete(id); this.envs.delete(id); this.memories.delete(id); + this.extensionSelections.delete(id); for (const key of this.seqs.keys()) { if (key.startsWith(`${id}/`)) this.seqs.delete(key); } diff --git a/apps/server/src/store/sessionStore.ts b/apps/server/src/store/sessionStore.ts index 88f009b9..405ba312 100644 --- a/apps/server/src/store/sessionStore.ts +++ b/apps/server/src/store/sessionStore.ts @@ -3,6 +3,7 @@ import { type Capability, type ChatMessage, type ConnectorDescriptor, + type ExtensionSelection, type MemoryBinding, type PinnedArtifact, type Session, @@ -169,6 +170,18 @@ export interface SessionStore { id: string, memory: MemoryBinding | undefined, ): Promise; + /** + * A session's global-extension selection (the session layer of the opt-in), + * read synchronously so the spawn path can fold it without awaiting — like + * {@link SessionStore.selectorsOf}. Returns `undefined` when the session set + * none (inherit the bundle policy). + */ + extensionsOf(id: string): ExtensionSelection | undefined; + /** Persists (or, with `undefined`, clears to inherit) the session's selection. */ + setSessionExtensions( + id: string, + extensions: ExtensionSelection | undefined, + ): Promise; /** * A session's selector tags, read synchronously so the remote gateway's * hot-path routing can match a selector-scoped host without awaiting. Returns diff --git a/apps/server/src/store/sqliteSessionStore.test.ts b/apps/server/src/store/sqliteSessionStore.test.ts index 39c67471..509effa1 100644 --- a/apps/server/src/store/sqliteSessionStore.test.ts +++ b/apps/server/src/store/sqliteSessionStore.test.ts @@ -98,6 +98,49 @@ test("the sessions table has a nullable env column", () => { assert.equal(env.notnull, 0, "and left it nullable"); }); +test("extensionsOf round-trips every selection shape, and clears to inherit", async () => { + const store = newStore(); + + // A fresh session inherits the bundle policy: no selection of its own. + const session = await store.createSession({ name: "exts" }); + assert.equal(store.extensionsOf(session.id), undefined); + + await store.setSessionExtensions(session.id, "all"); + assert.equal(store.extensionsOf(session.id), "all"); + + await store.setSessionExtensions(session.id, "none"); + assert.equal(store.extensionsOf(session.id), "none"); + + await store.setSessionExtensions(session.id, ["a", "b"]); + assert.deepEqual(store.extensionsOf(session.id), ["a", "b"]); + + // Resetting to inherit stores NULL and reads back as undefined. + await store.setSessionExtensions(session.id, undefined); + assert.equal(store.extensionsOf(session.id), undefined); +}); + +test("extensionsOf reads a selection back from a cold store (no cache)", async () => { + const db = openDb(":memory:"); + const warm = new SqliteSessionStore(db); + const session = await warm.createSession({ name: "persisted" }); + await warm.setSessionExtensions(session.id, ["x"]); + + // A second store over the same DB has an empty cache, forcing a DB read. + const cold = new SqliteSessionStore(db); + assert.deepEqual(cold.extensionsOf(session.id), ["x"]); +}); + +test("the sessions table has a nullable extensions column", () => { + const db = openDb(":memory:"); + const columns = db.$client.prepare("PRAGMA table_info(sessions)").all() as { + name: string; + notnull: number; + }[]; + const extensions = columns.find((column) => column.name === "extensions"); + assert.ok(extensions, "the migration added the extensions column"); + assert.equal(extensions.notnull, 0, "and left it nullable"); +}); + test("tags round-trip on the wire session and through selectorsOf", async () => { const store = newStore(); diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index 6c5a3ce9..2fa03e55 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -7,6 +7,7 @@ import { type ChatMessage, connectorFor, DEFAULT_SESSION_ACCESS_POLICY, + type ExtensionSelection, type MemoryBinding, type PinnedArtifact, type Session, @@ -139,6 +140,15 @@ export class SqliteSessionStore implements SessionStore { private readonly rootPaths = new Map(); /** Caches sessionId -> selector tags so `selectorsOf` stays a sync lookup. */ private readonly selectors = new Map(); + /** + * Caches sessionId -> global-extension selection so `extensionsOf` stays a + * sync lookup. `null` records a session known to carry no selection (inherit), + * distinct from an uncached entry. + */ + private readonly extensionSelections = new Map< + string, + ExtensionSelection | null + >(); private readonly db: Db; /** * The resource catalog a pinned artifact mirrors into, so an artifact is a @@ -209,6 +219,33 @@ export class SqliteSessionStore implements SessionStore { .run(); } + extensionsOf(id: string): ExtensionSelection | undefined { + const cached = this.extensionSelections.get(id); + if (cached !== undefined) return cached === null ? undefined : cached; + const row = this.db + .select({ extensions: sessions.extensions }) + .from(sessions) + .where(eq(sessions.id, id)) + .get(); + const selection = row?.extensions + ? (JSON.parse(row.extensions) as ExtensionSelection) + : undefined; + this.extensionSelections.set(id, selection ?? null); + return selection; + } + + async setSessionExtensions( + id: string, + extensions: ExtensionSelection | undefined, + ): Promise { + this.db + .update(sessions) + .set({ extensions: jsonOrNull(extensions) }) + .where(eq(sessions.id, id)) + .run(); + this.extensionSelections.set(id, extensions ?? null); + } + selectorsOf(id: string): string[] { const cached = this.selectors.get(id); if (cached) return cached; @@ -271,6 +308,7 @@ export class SqliteSessionStore implements SessionStore { this.rootPaths.set(id, rootPath); this.selectors.set(id, session.tags); + this.extensionSelections.set(id, null); await this.recordAgent(id, { id: PRIME_AGENT_ID, role: "prime", diff --git a/docs/config-bundle-format.md b/docs/config-bundle-format.md index c5b4e770..2689d618 100644 --- a/docs/config-bundle-format.md +++ b/docs/config-bundle-format.md @@ -7,9 +7,9 @@ runs Pi with the bundle's prompts, tools, and extensions applied per-session instead of the global server defaults. This document is the human-readable spec. The machine-readable counterpart is -[`shared/configBundle.ts`](../shared/configBundle.ts) (types + constants) and -the validator in -[`server/src/pi/config/manifest.ts`](../server/src/pi/config/manifest.ts). +[`packages/shared/src/configBundle.ts`](../packages/shared/src/configBundle.ts) +(types + constants) and the validator in +[`apps/server/src/pi/config/manifest.ts`](../apps/server/src/pi/config/manifest.ts). A bundle can also ship its own sandboxed UI components (input panels and custom message components). That extension — its `ui:` manifest block, element @@ -40,6 +40,8 @@ my-bundle.zip │ └── *.md # seed files copied into the workspace ├── tools/ │ └── *.ts # optional custom tool extensions (Pi --extension) +├── ui/ +│ └── *.tsx # optional sandboxed UI components (declared in ui:) └── triggers/ └── *.ts # optional trigger transforms (signal -> prompt) ``` @@ -74,6 +76,13 @@ The manifest is YAML with `schemaVersion: 1`. - `tools` (optional) — Prime's tool allowlist. Orchestration tools (`spawn_subagent`, etc.) and `read_room` are added automatically at spawn, so they need not be listed. +- `welcomeMessage` (optional) — relative path to a markdown file pre-seeded as + Prime's first message at session creation, so the agent "speaks first" with no + LLM call. +- `model` (optional) — `provider/model` id Prime runs; falls back to the server + default. +- `thinking` (optional) — thinking depth (`off`, `minimal`, `low`, `medium`, + `high`, `xhigh`); falls back to the server default. ### `subagents` (optional) @@ -81,6 +90,10 @@ The manifest is YAML with `schemaVersion: 1`. prompt (typically `prompts/subagent.md`). - `defaultTools` (optional) — default tool allowlist for sub-agents without an explicit list. +- `defaultModel` (optional) — default `provider/model` id for sub-agents lacking + an explicit one. +- `defaultThinking` (optional) — default thinking depth for sub-agents lacking an + explicit one. ### Optional entity lists @@ -98,7 +111,87 @@ no absolute paths). Sub-agent templates under `agents/` use the existing frontmatter format (`name`, `description`, `tools`, then a markdown body), identical to the server's bundled templates such as -[`server/src/pi/agents/worker.md`](../server/src/pi/agents/worker.md). +[`apps/server/src/pi/agents/worker.md`](../apps/server/src/pi/agents/worker.md). + +### `memoryBinding` (optional) + +The default memory binding for sessions created from this bundle — how the +agent's pluggable memory store is wired. A `CreateSessionRequest.memory` +overrides it. This is distinct from the `memory` list above, which only names +seed markdown files copied into the workspace. + +- `kind: simple-md` — a single agent-writable `MEMORY.md`. `root` (optional) is a + session-root-relative store directory. +- `kind: pi-memory` — the `pi-memory` store. `root` (optional) plus a `config` + map (`exitSummary`, `snapshot`, `qmdUpdate`, `search`, `exitSummaryModel`). +- `kind: composite` — a `children` array combining multiple bindings. + +### `globalExtensions` (optional) + +Opt-in policy for the server's **global extensions** (packages installed via the +`/api/extensions` registry and the Settings → Extensions UI). This is the bundle +layer of a two-layer opt-in; a session's own selection overlays it. It takes one +of three forms: + +- `all` — include every installed global extension; a session may override it. +- a list of extension ids — include these; a session may extend the list or drop + to none, but cannot remove individual bundle ids. +- `{ only: [, ...] }` — include exactly these ids, locked; a session may not + override (a defined session selection is rejected). `{ only: [] }` locks the + session to no extensions. + +Each id is a slug matching `^[a-z0-9][a-z0-9-]*$`. When omitted, sessions default +to inheriting "no global extensions" unless their own selection opts in. This is +distinct from the `extensions` list above, which ships the bundle's own +`tools/*.ts` sources. + +**`all` — include every installed global extension.** A session can still opt out +with `none` or narrow to a specific list; if the session says nothing, it inherits +`all`, so newly installed extensions are picked up on the next spawn. + +```yaml +globalExtensions: all +``` + +**A list of ids — a default set the session can extend.** These ids are always +included. A session may add more ids (the result is the union) or drop to `none`, +but it cannot remove an individual bundle id. + +```yaml +globalExtensions: + - web-search + - pdf-tools +``` + +**`only` — a locked set the session cannot override.** Exactly these ids are +applied; a session that supplies its own `extensions` selection is rejected with +`409`. Use the empty form to lock a session to no global extensions. + +```yaml +globalExtensions: + only: + - web-search +``` + +```yaml +globalExtensions: + only: [] +``` + +How each bundle form resolves once a session's own selection overlays it: + +| Bundle form | Session omits | Session list | Session `none` | Session `all` | +| ----------------- | ------------- | ---------------------- | -------------- | ------------- | +| (field absent) | none | that list | none | all | +| `all` | all | that list (exactly) | none | all | +| `[a, b]` | `[a, b]` | `[a, b]` + session ids | none | all | +| `{ only: [...] }` | those ids | 409 rejected | 409 rejected | 409 rejected | + +### `ui` (optional) + +Sandboxed UI components the bundle ships (input panels and custom message +components). The `ui:` block, its element vocabulary, host bridge, and authoring +rules are specified separately under [`bundle-ui/`](bundle-ui/README.md). ### `triggers` (optional) @@ -126,6 +219,8 @@ the user asks. - `schedule` (required when `kind: schedule`) — a map with `every` (a duration string such as `"1h"`, `"30m"`, `"45s"`) or `cron` (a standard cron expression evaluated server-side). Supply one. +- `enabled` (optional) — whether the trigger is armed on install. Defaults to + `true`. A `callback` trigger is reachable at `POST /api/sessions//triggers//callback/`; the secret is @@ -164,6 +259,9 @@ tags: prime: systemPrompt: prompts/prime.md + welcomeMessage: prompts/welcome.md + model: anthropic/claude-sonnet-4 + thinking: medium tools: - read - write @@ -188,6 +286,9 @@ memory: - memory/project-notes.md extensions: - tools/citations.ts +globalExtensions: + - web-search + - pdf-tools triggers: - name: hourly-digest @@ -213,16 +314,25 @@ software: ## How entities map onto Pi At session creation the bundle is installed into the session workspace and its -entities are applied as Pi spawn flags (wired in Phase 3): +entities are applied as Pi spawn flags: - `prompts/prime.md` / `prompts/subagent.md` → `--append-system-prompt` - `prime.tools` / `subagents.defaultTools` → `--tools` +- `prime.model` / `subagents.defaultModel` → `--provider` / `--model` +- `prime.thinking` / `subagents.defaultThinking` → `--thinking` - `skills//SKILL.md` → `--skill ` - `workflows/.md` → `--prompt-template ` - `tools/*.ts` → `--extension ` +- `globalExtensions` → resolved against the session's selection into the set of + opted-in global extensions; each contributes its resources via `--extension` / + `--skill` / `--prompt-template` and its tool names are unioned into `--tools`. - `rules/AGENTS.md` and `memory/*.md` → copied to the workspace root, where Pi auto-discovers `AGENTS.md`/`CLAUDE.md` and memory files from `cwd`. - `agents/.md` → loaded as sub-agent templates Prime can spawn. +- `prime.welcomeMessage` → pre-seeded as Prime's first message at creation (no + Pi flag, no LLM call). +- `memoryBinding` → resolves the session's memory store; no direct Pi flag. +- `ui.components` → transpiled on upload and rendered by the host; no Pi mapping. - `triggers` → seeded into the session's mutable trigger store; `triggers/*.ts` handlers are transpiled on upload and executed server-side when a trigger fires (no Pi mapping; triggers deliver prompts via the same path as user diff --git a/docs/server/extensions-and-prompts.md b/docs/server/extensions-and-prompts.md index cb80744a..5245666a 100644 --- a/docs/server/extensions-and-prompts.md +++ b/docs/server/extensions-and-prompts.md @@ -237,3 +237,122 @@ prompt is written to a per-agent file and handed to Pi as a `--append-system-prompt ` (Pi reads file contents when the value is an existing path). It embeds the unbounded memory and host-resource preambles, so passing it inline would eventually overflow the kernel's argv limit (`E2BIG`). + +--- + +## Global extensions + +Beyond the always-on extensions and a bundle's own resources, an instance can +install **global extensions** — Pi packages (extensions, skills, and prompt +templates) shared across every session. Unlike the always-on set (compiled into +the image) and bundle resources (scoped to one session), global extensions are +installed at runtime, persist on the data volume, and are opted into per session. + +The relevant code lives under [server/src/extensions/](../../server/src/extensions), +with the HTTP surface in +[routes/globalExtensions.ts](../../server/src/routes/globalExtensions.ts). + +### Marketplaces (the install source abstraction) + +An install always comes from a **marketplace** — an abstraction over "where the +bytes come from", keyed by id. A `":"` source string selects +one (`MarketplaceRegistry.parseSource`); only the first `:` separates them, so a +ref may itself contain `:` (e.g. a git url). The built-ins: + +| Marketplace | Ref example | Install | +| ----------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `npm` | `npm:pi-weather@1.2.0` | `npm install` into the install's `pkg/`; searchable (npm registry, `keywords:pi-package`) | +| `git` | `git:github.com/user/repo#v1` | `npm install` of a normalized `git+https` spec | +| `file` | uploaded `.tgz` / `.zip` / `.ts` | tarball → npm; zip → extracted (with a `..` traversal guard) then its deps installed; lone code file → copied | + +"Marketplace" is deliberately an abstraction: a proprietary or private source is +one new class implementing `ExtensionMarketplace` plus one `register()` call in +`createMarketplaceRegistry()` — nothing in the install or spawn path changes. + +### Storage and reconcile-on-boot + +Each install lives under `EXTENSIONS_ROOT` (defaults to +`SESSIONS_ROOT/.extensions`, so it rides the same PVC as sessions) as +`/extension.json` (metadata), a `/pkg/` package 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 (mirrors `FileAgentBundleStore`). + +On boot, `index.ts` awaits `GlobalExtensionService.reconcile()` **before** the +server listens. Reconcile loads the persisted installs into an in-memory cache +and, for any whose resource files are missing from the volume (a fresh pod on a +reused volume, or an interrupted write), reinstalls them from the recorded source +(or the preserved `source/` upload). A reinstall that fails is kept as `error` +rather than throwing, so one bad install never blocks startup, and a later retry +can recover it. This is what makes an install survive a pod restart. + +### What a package contributes + +`resolvePackageResources` reads a package root into the Pi resource paths it +contributes, following Pi's own discovery rules: a declared `pi` manifest block +(`extensions` / `skills` / `prompts`) wins per resource type; an undeclared type +falls back to its conventional directory (`extensions/`, `skills/`, `prompts/`); +a package with none of those exposes a root `SKILL.md` as one skill and lone +code files as extensions. Tool names come from `tangent.tools` (then `pi.tools`). +Paths are stored package-root-relative and resolved to absolute at spawn. + +When the installer doesn't pass an explicit `tools` list, `resolveInstallTools` +decides the recorded allowlist: declared tool names (`tangent.tools` / +`pi.tools`) win; otherwise the extension is loaded in an isolated Node +subprocess (`probeExtensionTools`) through Pi's own extension loader to +enumerate exactly the tools it registers, which are pre-filled into `tools` so +opting in grants those and nothing more. The probe resolves Pi's loader from an +explicit `PI_EXTENSION_LOADER`, the `pi` binary's package, or the workspace +pnpm store; if it can't run at all, a package that ships extension code falls +back to `["*"]`, and a skills/prompts-only package records `[]`. Admins can +edit the recorded list afterward via `PATCH /api/extensions/:id`. + +### Per-session opt-in: bundle policy × session selection + +Which installed extensions an agent gets is resolved from two layers by the pure +`resolveExtensionSelection(policy, selection)`: + +- **Session selection** (`sessions.extensions`, nullable): `"all"`, `"none"`, a + list of ids, or absent (inherit the bundle policy). Set via + `PUT /api/sessions/:id/extensions` (a `null` body resets it to inherit). +- **Bundle policy** (`BundleManifest.globalExtensions`): `"all"` (session may + override), a list of ids (session may extend the list, or clear it with + `"none"`), or `{ only: [...] }` — **locked**: the session cannot override it, + and a create/PUT that tries is rejected with `409`. + +| Bundle policy \ Session | absent | `"all"` | `"none"` | `[b]` | +| ----------------------- | ------ | ------- | -------- | -------------- | +| none | none | all | none | `[b]` | +| `"all"` | all | all | none | `[b]` | +| `[a]` | `[a]` | all | none | `[a, b]` | +| `{ only: [a] }` | `[a]` | `[a]` | `[a]` | `[a]` (locked) | + +The resolved result (`"all"` or a concrete id list) is expanded — `"all"` means +every currently-ready install — and handed to `GlobalExtensionService.spawnPlan`. + +### Folding into the spawn + +`spawnPlan(ids)` is synchronous (the agent manager calls it on the hot spawn +path, fed by the boot-seeded cache) and returns the absolute `--extension`, +`--skill`, and `--prompt-template` paths plus the tool allowlist for those +installs. Unknown or errored ids are skipped with a warning so one bad install +never blocks a session. `buildPiArgs` folds the plan in exactly like the memory +plan: the resource flags are appended and `allowlistTools` is unioned into +`--tools` (recall extension tools must also be allowlisted, above). + +### HTTP surface + +`/api/extensions` (reads are open; writes require admin via `requireAdmin`): + +- `GET /api/extensions` — list installs (with status). +- `GET /api/extensions/marketplaces` — available marketplaces (+ `canSearch`). +- `GET /api/extensions/marketplaces/:id/search?q=` — search one marketplace. +- `POST /api/extensions` — install from a JSON `{ source }` or a multipart file + upload (`409` on an id conflict without `replace`, `400` on a package that + declares no resources). +- `PATCH /api/extensions/:id` — patch an install's tool allowlist / display name. +- `DELETE /api/extensions/:id` — uninstall. + +This surface is also exposed in the web app under Settings, Extensions: anyone +can view installs; admins can install (npm/git source, marketplace search, or +file upload), edit a tool allowlist, and uninstall. diff --git a/packages/shared/src/configBundle.ts b/packages/shared/src/configBundle.ts index 0e80a9d8..05b8d7ca 100644 --- a/packages/shared/src/configBundle.ts +++ b/packages/shared/src/configBundle.ts @@ -11,7 +11,11 @@ * Phase 1 defines the format only; runtime wiring (install/spawn) arrives later. */ -import type { MemoryBinding, ThinkingLevel } from "./contracts.ts"; +import type { + BundleExtensionPolicy, + MemoryBinding, + ThinkingLevel, +} from "./contracts.ts"; /** * Manifest schema version. Bump this integer when a change to {@link @@ -213,6 +217,13 @@ export interface BundleManifest { memoryBinding?: MemoryBinding; /** Explicit custom tool extension paths (`tools/*.ts`). */ extensions?: string[]; + /** + * Global-extension opt-in policy for sessions created from this bundle (the + * bundle layer, overlaid by a session's selection). See {@link + * BundleExtensionPolicy}. Distinct from {@link BundleManifest.extensions}, + * which lists the bundle's own bundled tool sources. + */ + globalExtensions?: BundleExtensionPolicy; /** Optional software requirements keyed by package manager. */ software?: BundleSoftwareRequirements; /** Sandboxed UI components shipped by the bundle. */ diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index d0878fb9..60d95255 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -1488,6 +1488,135 @@ export interface ListAgentBundlesResponse { bundles: AgentBundleMeta[]; } +/** + * Health of an installed global extension. `ready` when every resolved resource + * path is present on disk; `error` when the boot reconcile could not restore it + * (the install is kept so a later reinstall can recover it). + */ +export type GlobalExtensionStatus = "ready" | "error"; + +/** + * The source an installed global extension was fetched from, so a boot reconcile + * can reinstall it when its files are missing from the volume. `marketplace` is + * a registered marketplace id (`npm`, `git`, `file`, or a future proprietary + * source); `ref` is the install spec that marketplace understands (an npm spec, + * a git url, or an uploaded file's name for `file`). + */ +export interface GlobalExtensionSource { + marketplace: string; + ref: string; +} + +/** + * Resolved resource paths an installed extension contributes to a Pi spawn, all + * relative to the package root (see {@link GlobalExtensionMeta.packageRoot}). + * `extensions` are `--extension` files, `skills` are `--skill` directories (each + * holding a `SKILL.md`) or files, and `prompts` are `--prompt-template` files. + */ +export interface GlobalExtensionResources { + extensions: string[]; + skills: string[]; + prompts: string[]; +} + +/** + * Metadata for one installed global extension, persisted as `extension.json` + * under `EXTENSIONS_ROOT//` and returned by the `/api/extensions` API. + */ +export interface GlobalExtensionMeta { + /** Stable slug id (`^[a-z0-9][a-z0-9-]*$`); also the storage directory name. */ + id: string; + /** Display name (the package name when available). */ + name: string; + /** Installed package version, when the source carries one. */ + version?: string; + /** Where it was installed from, for boot-time reinstall. */ + source: GlobalExtensionSource; + /** + * Tool names the extension registers, unioned into every opted-in agent's + * `--tools` allowlist (Pi strips extension tools that aren't listed). + */ + tools: string[]; + /** Package-root-relative resource paths (see {@link GlobalExtensionResources}). */ + resources: GlobalExtensionResources; + /** + * The package root relative to the install's `pkg/` directory: `node_modules/ + * ` for an npm/git source, `.` for an extracted archive or single file. + */ + packageRoot: string; + /** ISO-8601 timestamp the extension was first installed. */ + installedAt: string; + /** ISO-8601 timestamp of the most recent (re)install. */ + updatedAt: string; + /** Runtime health, reconciled at boot. */ + status: GlobalExtensionStatus; + /** Human-readable reason when {@link GlobalExtensionMeta.status} is `error`. */ + error?: string; +} + +/** Response from `GET /api/extensions`: the installed extensions. */ +export interface ListGlobalExtensionsResponse { + extensions: GlobalExtensionMeta[]; +} + +/** + * Body for `POST /api/extensions` (JSON form, for `npm`/`git` sources). File + * uploads use the multipart form instead (a `file` field plus `id`/`tools`). + */ +export interface InstallGlobalExtensionRequest { + /** Install source, `":"` (e.g. `npm:pi-foo@1.2.0`). */ + source: string; + /** Override the id derived from the package name. */ + id?: string; + /** Tool names to allowlist; defaults to the package's declared tools. */ + tools?: string[]; + /** Replace an existing install with the same id instead of conflicting. */ + replace?: boolean; +} + +/** One discoverable package from a marketplace's optional `search`. */ +export interface MarketplaceListing { + /** The install ref for this listing (e.g. the npm package name). */ + ref: string; + /** Display name. */ + name: string; + /** Short description, when the marketplace provides one. */ + description?: string; + /** Latest version, when known. */ + version?: string; +} + +/** Response from `GET /api/extensions/marketplaces`: the registered sources. */ +export interface ListMarketplacesResponse { + marketplaces: { id: string; canSearch: boolean }[]; +} + +/** Response from `GET /api/extensions/marketplaces/:id/search`. */ +export interface SearchMarketplaceResponse { + listings: MarketplaceListing[]; +} + +/** + * A session's global-extension selection (the session layer of the opt-in). It + * overlays the bundle's {@link BundleExtensionPolicy}. An absent selection + * (`undefined` at the store, `null` on the wire reset) means "inherit the + * bundle policy"; `"all"` opts into every installed extension, `"none"` into + * none, and a list names specific extension ids. + */ +export type ExtensionSelection = "all" | "none" | string[]; + +/** + * A bundle's global-extension policy, from `tangent.yaml` `globalExtensions` + * (the bundle layer of the opt-in): + * - `"all"`: include every installed extension; the session may override it. + * - `string[]`: include these ids; the session may extend the list, or drop to + * `"none"`, but cannot remove individual bundle ids. + * - `{ only: string[] }`: include exactly these ids, locked; the session may not + * override (a defined session selection is rejected). `{ only: [] }` locks the + * session to no extensions. + */ +export type BundleExtensionPolicy = "all" | string[] | { only: string[] }; + export interface CreateSessionRequest { name?: string; /** Marketplace agent bundle id to create the session from. */ @@ -1510,6 +1639,12 @@ export interface CreateSessionRequest { * the bundle's declared binding (or none). Resolved and persisted at create. */ memory?: MemoryBinding; + /** + * Global-extension selection for the session, overlaying the bundle's + * {@link BundleExtensionPolicy}. Omit to inherit the bundle policy. Rejected + * (409) when the bundle locks the policy with `{ only }`. + */ + extensions?: ExtensionSelection; } export interface UpdateSessionRequest { @@ -1520,6 +1655,15 @@ export interface UpdateSessionRequest { tags?: string[]; } +/** + * Body for `PUT /api/sessions/:id/extensions`: replaces the session's + * global-extension selection. `null` resets it to inherit the bundle policy. + * Takes effect on each agent's next spawn. + */ +export interface SetSessionExtensionsRequest { + extensions: ExtensionSelection | null; +} + /** Response from `POST /api/sessions/:id/files`: the stored attachments. */ export interface UploadFilesResponse { files: Attachment[];