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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions App/backend/local-api-contracts/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ export const PluginArtifactRefSchema = z.object({
mediaType: z.string().trim().min(1),
uri: z.string().trim().min(1),
downloadUri: z.string().trim().min(1).optional(),
/** Absolute Host-published path when the artifact also belongs to the conversation workspace. */
path: z.string().trim().min(1).optional(),
role: PluginArtifactRoleSchema.optional()
});
export type PluginArtifactRef = z.infer<typeof PluginArtifactRefSchema>;
Expand Down
19 changes: 16 additions & 3 deletions App/backend/local-api-contracts/tests/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,25 @@ describe("CapabilityEventSchema", () => {
});

it("wraps generic plugin events with call routing context", () => {
expect(PluginCapabilityEventPayloadSchema.parse({
const parsed = PluginCapabilityEventPayloadSchema.parse({
pluginId: manifest.id,
capabilityId: "review",
callId: "call-1",
conversationId: "conversation-1",
event: { type: "artifact", artifact: { id: "report", name: "report.md", mediaType: "text/markdown", uri: "file:///report.md" } }
}).event.type).toBe("artifact");
event: {
type: "artifact",
artifact: {
id: "report",
name: "report.md",
mediaType: "text/markdown",
uri: "/api/v1/plugins/review/artifacts/token/preview",
path: "/workspace/outputs/review/task-1/report.md"
}
}
});
expect(parsed.event.type).toBe("artifact");
if (parsed.event.type === "artifact") {
expect(parsed.event.artifact.path).toBe("/workspace/outputs/review/task-1/report.md");
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ const McpReloadResponseSchema = z.object({
requires_restart: z.boolean()
});

const WorkspaceEnvironmentResponseSchema = z.object({
snapshot: z.object({
cwd: z.string()
})
});

export interface CreateHttpMemmyAgentAdminClientOptions {
/** Memmy-agent WebUI HTTP base URL. */
baseUrl?: string;
Expand Down Expand Up @@ -71,6 +77,15 @@ class HttpMemmyAgentAdminClient implements MemmyAgentAdminClient {
return this.request("/api/channels/status", ChannelConnectionsResponseSchema);
}

async getSessionWorkspace(sessionKey: string): Promise<string | null> {
const normalizedSessionKey = normalizeGuiSessionKey(sessionKey);
const environment = await this.request(
`/api/sessions/${encodeURIComponent(normalizedSessionKey)}/environment`,
WorkspaceEnvironmentResponseSchema
);
return environment.snapshot.cwd.trim() || null;
}

async configureChannel(runtimeChannel: string) {
return this.request(`/api/channels/${encodeURIComponent(runtimeChannel)}/configure`, ChannelRuntimeActionResponseSchema, { method: "POST" });
}
Expand Down Expand Up @@ -140,6 +155,13 @@ class HttpMemmyAgentAdminClient implements MemmyAgentAdminClient {
}
}

function normalizeGuiSessionKey(value: string): string {
const sessionKey = value.trim();
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(sessionKey)
? `websocket:${sessionKey}`
: sessionKey;
}

function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/g, "") || DEFAULT_MEMMY_AGENT_ADMIN_BASE_URL;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
export interface MemmyAgentAdminClient {
getChannelDefinitions(): Promise<ChannelDefinitionsResponse>;
getChannelConnections(): Promise<ChannelConnectionsResponse>;
/** Returns the canonical workspace currently bound to a WebUI conversation. */
getSessionWorkspace?(sessionKey: string): Promise<string | null>;
configureChannel(runtimeChannel: string): Promise<{ status: ChannelStatus; running: boolean }>;
stopChannel(runtimeChannel: string): Promise<{ status: ChannelStatus; running: boolean }>;
startWeixinLogin(): Promise<{ status: ChannelStatus; qrCodeDataUrl?: string; pollToken?: string }>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ describe("http memmy-agent admin client", () => {
});
return;
}
if (request.url === "/api/sessions/websocket%3Achat-1/environment") {
sendJson(response, { snapshot: { cwd: "/Users/test/workspace" } });
return;
}
if (request.url === "/api/sessions/websocket%3A127c0697-c224-47e0-a032-684c7c1c2abb/environment") {
sendJson(response, { snapshot: { cwd: "/Users/test/standalone" } });
return;
}
if (request.url === "/api/channels/feishu/configure") {
sendJson(response, { status: "connected", running: true });
return;
Expand All @@ -78,6 +86,8 @@ describe("http memmy-agent admin client", () => {
await expect(client.getChannelConnections()).resolves.toEqual({
connections: [{ id: "channel-wechat-local", provider: "wechat", runtimeChannel: "weixin", status: "connected", running: true, displayName: "WeChat" }]
});
await expect(client.getSessionWorkspace?.("websocket:chat-1")).resolves.toBe("/Users/test/workspace");
await expect(client.getSessionWorkspace?.("127c0697-c224-47e0-a032-684c7c1c2abb")).resolves.toBe("/Users/test/standalone");
await expect(client.configureChannel("feishu")).resolves.toEqual({ status: "connected", running: true });
await expect(client.startWeixinLogin()).resolves.toMatchObject({ status: "pendingQr", pollToken: "poll-1" });
await expect(client.reloadMcpConfig()).resolves.toEqual({
Expand All @@ -88,6 +98,8 @@ describe("http memmy-agent admin client", () => {
expect(requests).toEqual([
{ method: "GET", path: "/webui/bootstrap", authorization: undefined },
{ method: "GET", path: "/api/channels/status", authorization: "Bearer boot-token" },
{ method: "GET", path: "/api/sessions/websocket%3Achat-1/environment", authorization: "Bearer boot-token" },
{ method: "GET", path: "/api/sessions/websocket%3A127c0697-c224-47e0-a032-684c7c1c2abb/environment", authorization: "Bearer boot-token" },
{ method: "POST", path: "/api/channels/feishu/configure", authorization: "Bearer boot-token" },
{ method: "POST", path: "/api/channels/weixin/login/start", authorization: "Bearer boot-token" },
{ method: "POST", path: "/api/settings/mcp-presets/reload", authorization: "Bearer boot-token" }
Expand Down
16 changes: 12 additions & 4 deletions App/backend/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba
invoker: createPluginAsrService({ asr: asrService, audioRoots: pluginFileInputRoots })
}
]);
const memmyAgentAdminClient =
options.memmyAgentAdminClient ??
createHttpMemmyAgentAdminClient({ bootstrapSecret: options.memmyAgentAdminBootstrapSecret });
const pluginRuntimeHost = options.pluginRuntimeHost ?? createPluginRuntimeHost(new PluginAdapterRegistry([
createMcpPluginAdapter(),
createHttpPluginAdapter(),
Expand All @@ -220,7 +223,15 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba
}),
skillManager: createPluginSkillManager({ skillsRoot: join(resolveAgentWorkspace(process.env), "skills") }),
localArtifactService: createPluginLocalArtifactService({
pluginDataRoot: join(dirname(options.appStateStore.databasePath), "plugin-data")
pluginDataRoot: join(dirname(options.appStateStore.databasePath), "plugin-data"),
resolveWorkspace: async (conversationId) => {
try {
return await memmyAgentAdminClient.getSessionWorkspace?.(conversationId) ?? null;
} catch {
// Non-WebUI invocations do not have a conversation workspace to publish into.
return null;
}
}
}),
isEntitlementGranted
});
Expand All @@ -235,9 +246,6 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba
createSkillDistributionService({
targetRegistry: skillTargetRegistry
});
const memmyAgentAdminClient =
options.memmyAgentAdminClient ??
createHttpMemmyAgentAdminClient({ bootstrapSecret: options.memmyAgentAdminBootstrapSecret });
const resolveAnalyticsUserId = () => {
const session = accountSessionRepository.get();
if (!session.authenticated) return null;
Expand Down
109 changes: 104 additions & 5 deletions App/backend/src/services/plugin-local-artifact-service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** Validates plugin-produced local files and exposes opaque local API references. */
import { randomUUID } from "node:crypto";
import { lstat, realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";
import { constants as fsConstants } from "node:fs";
import { copyFile, lstat, mkdir, realpath, rename, rm } from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import type { PluginArtifactRef, PluginPermission } from "@memmy/local-api-contracts";

Expand All @@ -12,20 +13,32 @@ export interface HostedPluginArtifact {
}

export interface PluginLocalArtifactService {
host(plugin: { id: string; approvedPermissions: PluginPermission[]; config: Record<string, unknown> }, artifact: PluginArtifactRef): Promise<PluginArtifactRef>;
host(
plugin: { id: string; approvedPermissions: PluginPermission[]; config: Record<string, unknown> },
artifact: PluginArtifactRef,
context?: PluginArtifactHostContext
): Promise<PluginArtifactRef>;
open(pluginId: string, token: string): Promise<HostedPluginArtifact>;
revokePlugin(pluginId: string): void;
}

export interface PluginArtifactHostContext {
conversationId: string;
taskId?: string;
callId: string;
}

export interface CreatePluginLocalArtifactServiceOptions {
/** Host-owned parent directory containing one writable data directory per plugin. */
pluginDataRoot?: string;
/** Resolves the workspace currently bound to one Agent conversation. */
resolveWorkspace?: (conversationId: string) => Promise<string | null>;
}

export function createPluginLocalArtifactService(options: CreatePluginLocalArtifactServiceOptions = {}): PluginLocalArtifactService {
const artifacts = new Map<string, HostedPluginArtifact & { pluginId: string }>();
return {
async host(plugin, artifact) {
async host(plugin, artifact, context) {
let url: URL;
try {
url = new URL(artifact.uri);
Expand Down Expand Up @@ -56,7 +69,15 @@ export function createPluginLocalArtifactService(options: CreatePluginLocalArtif
const token = randomUUID();
artifacts.set(token, { pluginId: plugin.id, path, name: artifact.name, mediaType: artifact.mediaType });
const base = `/api/v1/plugins/${encodeURIComponent(plugin.id)}/artifacts/${encodeURIComponent(token)}`;
return { ...artifact, uri: `${base}/preview`, downloadUri: `${base}/download` };
const publishedPath = context
? await publishArtifactToWorkspace(path, plugin.id, artifact.name, context, options.resolveWorkspace)
: null;
return {
...artifact,
uri: `${base}/preview`,
downloadUri: `${base}/download`,
...(publishedPath ? { path: publishedPath } : {})
};
},

async open(pluginId, token) {
Expand All @@ -74,6 +95,84 @@ export function createPluginLocalArtifactService(options: CreatePluginLocalArtif
};
}

async function publishArtifactToWorkspace(
sourcePath: string,
pluginId: string,
artifactName: string,
context: PluginArtifactHostContext,
resolveWorkspace: CreatePluginLocalArtifactServiceOptions["resolveWorkspace"]
): Promise<string | null> {
if (!resolveWorkspace || pluginId !== "literature-review" || !context.taskId) return null;
const workspacePath = await resolveWorkspace(context.conversationId);
if (!workspacePath) return null;
const workspace = await realpath(workspacePath);
const relativeName = safeArtifactRelativePath(artifactName);
const taskDirectory = safePathSegment(context.taskId, "task");
const parent = await ensureSafeDirectory(workspace, [
"outputs",
safePathSegment(pluginId, "plugin"),
taskDirectory,
...relativeName.slice(0, -1)
]);
const target = join(parent, relativeName.at(-1)!);
const temporary = `${target}.${randomUUID()}.tmp`;
try {
await copyFile(sourcePath, temporary, fsConstants.COPYFILE_EXCL);
try {
await rename(temporary, target);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST" && code !== "EPERM") throw error;
const existing = await lstat(target).catch(() => null);
if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
throw pluginArtifactError("Plugin artifact destination is not a regular workspace file");
}
await rm(target, { force: true });
await rename(temporary, target);
}
return await realpath(target);
} finally {
await rm(temporary, { force: true }).catch(() => undefined);
}
}

function safeArtifactRelativePath(value: string): string[] {
if (value.includes("\0") || value.includes("\\") || isAbsolute(value)) {
throw pluginArtifactError("Plugin artifact name is not a safe workspace-relative path");
}
const parts = value.split("/");
if (!parts.length || parts.some((part) => !part || part === "." || part === "..")) {
throw pluginArtifactError("Plugin artifact name is not a safe workspace-relative path");
}
return parts;
}

function safePathSegment(value: string, fallback: string): string {
const safe = value.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 160);
return safe && safe !== "." && safe !== ".." ? safe : fallback;
}

async function ensureSafeDirectory(root: string, parts: string[]): Promise<string> {
let current = root;
for (const part of parts) {
current = join(current, part);
const existing = await lstat(current).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!existing) {
await mkdir(current);
} else if (!existing.isDirectory() || existing.isSymbolicLink()) {
throw pluginArtifactError("Plugin artifact workspace destination contains an unsafe path");
}
const canonical = await realpath(current);
if (!isWithin(root, canonical)) {
throw pluginArtifactError("Plugin artifact workspace destination escapes the workspace");
}
}
return current;
}

async function approvedFilesystemRoots(
plugin: { id: string; approvedPermissions: PluginPermission[] },
pluginDataRoot?: string
Expand Down
42 changes: 40 additions & 2 deletions App/backend/src/services/plugin-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type UpdatePluginConfigInput
} from "@memmy/local-api-contracts";
import { Ajv } from "ajv";
import { fileURLToPath } from "node:url";
import type { PluginRegistry } from "../adapters/outbound/plugin-registry/index.js";
import type { PluginArtifactManager } from "../adapters/outbound/plugin-artifact/index.js";
import type { PluginRuntimeHost } from "../adapters/outbound/plugin-runtime/index.js";
Expand Down Expand Up @@ -327,15 +328,27 @@ export function createPluginService(options: CreatePluginServiceOptions): Plugin
const startedAt = Date.now();
let outcome: "success" | "error" | "interrupted" = "interrupted";
let errorCode: string | null = null;
const publishedPaths = new Map<string, string>();
try {
for await (const event of options.runtimeHost.invoke(call)) {
if (event.type === "result") outcome = "success";
if (event.type === "error") {
outcome = "error";
errorCode = event.code;
}
yield event.type === "artifact"
? { ...event, artifact: await localArtifacts.host(plugin, event.artifact) }
if (event.type === "artifact") {
const sourcePath = localArtifactPath(event.artifact.uri);
const artifact = await localArtifacts.host(plugin, event.artifact, {
conversationId: call.conversationId,
taskId: capabilityTaskId(call.input),
callId: call.callId
});
if (sourcePath && artifact.path) publishedPaths.set(sourcePath, artifact.path);
yield { ...event, artifact };
continue;
}
yield event.type === "result" && publishedPaths.size
? { ...event, output: replacePublishedPaths(event.output, publishedPaths) }
: event;
}
} catch (error) {
Expand Down Expand Up @@ -392,6 +405,31 @@ export function createPluginService(options: CreatePluginServiceOptions): Plugin
};
}

function capabilityTaskId(input: unknown): string | undefined {
if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
const taskId = (input as Record<string, unknown>).taskId;
return typeof taskId === "string" && taskId.trim() ? taskId.trim() : undefined;
}

function localArtifactPath(uri: string): string | null {
try {
const url = new URL(uri);
return url.protocol === "file:" ? fileURLToPath(url) : null;
} catch {
return null;
}
}

function replacePublishedPaths(value: unknown, paths: ReadonlyMap<string, string>): unknown {
if (typeof value === "string") return paths.get(value) ?? value;
if (Array.isArray(value)) return value.map((item) => replacePublishedPaths(item, paths));
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
key,
replacePublishedPaths(item, paths)
]));
}

function publicPlugin(plugin: PluginRecord): InstalledPlugin {
const { artifactHash: _artifactHash, rootPath: _rootPath, ...result } = plugin;
return result;
Expand Down
Loading
Loading