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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ artifacts/literature-review-*.json
!App/memmy-agent/src/tools/computer-history/win/.gitkeep
App/backend/src/adapters/outbound/skill-writer/workspace-bridge/memmy-workspace-bridge.mjs
Memory/src/agent-source/integration/workspace-bridge/memmy-workspace-bridge.mjs

# Office rendering executables and libraries are provisioned during packaging.
App/memmy-agent/extra-dependencies/office-rendering/*/bin/
App/memmy-agent/extra-dependencies/office-rendering/*/native/
/Mac软件打包/
3 changes: 2 additions & 1 deletion App/backend/local-api-contracts/src/memory-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1003,7 +1003,8 @@ export const ApiErrorCodeSchema = z.enum([
"toolkit_unsupported",
"model_config_changed",
"config_write_busy",
"account_model_preset_conflict"
"account_model_preset_conflict",
"plugin_entitlement_required"
]);
export type ApiErrorCode = z.infer<typeof ApiErrorCodeSchema>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ export function registerPluginRoutes(app: FastifyInstance, options: RegisterPlug
const { id, callId, interactionId } = InteractionParamsSchema.parse(request.params);
const { response } = PluginInteractionResponseInputSchema.parse(request.body);
await options.plugins.respond(id, callId, interactionId, response);
// Refresh keeps the same prompt active; every other response resolves it.
if (!(response && typeof response === "object" && "action" in response && response.action === "refresh")) {
options.progressBus.acknowledgePluginInteraction(id, callId, interactionId);
}
return reply.send({ ok: true });
})
);
Expand Down
3 changes: 3 additions & 0 deletions App/backend/src/adapters/inbound/local-api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,9 @@ function startSse(
payload: event
});
});
for (const payload of progressBus.pendingPluginEvents()) {
send({ id: randomUUID(), type: "plugin.capability_event", timestamp: new Date().toISOString(), payload });
}

const cleanup = () => {
// Clean up the heartbeat timer promptly after the client disconnects, avoiding orphaned tasks lingering in the background.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, it } from "vitest";
import { createLocalApiServer } from "../server.js";
import { createProgressBus } from "../../../../services/progress-bus.js";
import type { BackendServices } from "../../../../services/index.js";
import type { PermissionManager } from "../../../../permission/index.js";

it("replays a pending confirmation to a late SSE subscriber, excluding its answered guidance", async () => {
const bus = createProgressBus();
const call = { pluginId: "legal-labor", capabilityId: "session-start", callId: "pending-call", conversationId: "websocket:chat-1", origin: "agent" as const };
bus.emit("plugin.capability_event", { ...call, event: { type: "interaction", request: { type: "question", interactionId: "answered-guide", payload: {} } } });
bus.acknowledgePluginInteraction(call.pluginId, call.callId, "answered-guide");
bus.emit("plugin.capability_event", { ...call, event: { type: "interaction", request: { type: "file-input", interactionId: "pending-upload", payload: { title: "补充诊断材料" } } } });
const app = createLocalApiServer({
permissionManager: { verifyRuntimeToken: async () => true } as unknown as PermissionManager,
composioMcpToken: "test-only", services: { progressBus: bus } as BackendServices
});
const abort = new AbortController();
try {
const base = await app.listen({ port: 0, host: "127.0.0.1" });
const response = await fetch(`${base}/api/events?token=test-only`, { signal: abort.signal });
expect(response.status).toBe(200);
const reader = response.body!.getReader();
let content = "";
while (!content.includes("pending-upload")) {
const chunk = await reader.read();
if (chunk.done) break;
content += new TextDecoder().decode(chunk.value);
}
expect(content).toContain("plugin.capability_event");
expect(content).toContain('"callId":"pending-call"');
expect(content).not.toContain("answered-guide");
await reader.cancel();
} finally { abort.abort(); await app.close(); }
});
14 changes: 11 additions & 3 deletions App/backend/src/adapters/outbound/plugin-artifact/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,27 @@ export interface CreatePluginArtifactManagerOptions {
trustedLocalRoots?: readonly string[];
/** Supplies account credentials so a registry can gate entitlement-restricted downloads. */
authHeaders?: () => Record<string, string>;
/** Only this origin receives account credentials; signed storage URLs need none. */
authOrigin?: string;
}

export function createPluginArtifactManager(options: CreatePluginArtifactManagerOptions): PluginArtifactManager {
const configuredInstallRoot = resolve(options.installRoot);
const trustedLocalRoots = (options.trustedLocalRoots ?? []).map((root) => resolve(root));
const fetchFn = options.fetchFn ?? fetch.bind(globalThis);
const authOrigin = options.authOrigin ? new URL(options.authOrigin).origin : undefined;

return {
async install(release) {
if (!release.artifact) return { artifactHash: null, rootPath: null };
const bytes = "localPath" in release.artifact && release.artifact.localPath
? await readTrustedLocalArtifact(release.artifact.localPath, trustedLocalRoots)
: await downloadArtifact(assertArtifactUrl(release.artifact.url), fetchFn, options.authHeaders?.());
let bytes: Buffer;
if ("localPath" in release.artifact && release.artifact.localPath) {
bytes = await readTrustedLocalArtifact(release.artifact.localPath, trustedLocalRoots);
} else {
const url = assertArtifactUrl(release.artifact.url);
bytes = await downloadArtifact(url, fetchFn,
authOrigin && url.origin === authOrigin ? options.authHeaders?.() : undefined);
}
const digest = createHash("sha256").update(bytes).digest("hex");
if (digest !== release.artifact.sha256.toLowerCase()) {
throw Object.assign(new Error("Plugin artifact SHA-256 mismatch"), { code: "plugin_invalid" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ async function release(fileName = "runtime/plugin.sh") {
}

describe("PluginArtifactManager", () => {
it.each([
["https://registry.example/plugin.zip", true],
["https://memos-file.oss-cn-shanghai.aliyuncs.com/memmy/legal-plugins/legal.zip?Signature=test", false]
])("sends account credentials only to the registry origin: %s", async (url, receivesAuth) => {
root = mkdtempSync(join(tmpdir(), "memmy-plugin-artifact-"));
const input = await release();
input.release.artifact.url = url;
const fetchFn = vi.fn<typeof fetch>(async () => new Response(input.bytes));
const authHeaders = vi.fn(() => ({ authorization: "Bearer account-token" }));
const manager = createPluginArtifactManager({
installRoot: root, fetchFn, authHeaders, authOrigin: "https://registry.example/api"
});
await manager.install(input.release);
expect(fetchFn.mock.calls[0]?.[1]?.headers).toEqual({
accept: "application/zip, application/octet-stream",
...(receivesAuth ? { authorization: "Bearer account-token" } : {})
});
expect(authHeaders).toHaveBeenCalledTimes(receivesAuth ? 1 : 0);
expect(fetchFn.mock.calls[0]?.[1]?.redirect).toBe("error");
});

it("verifies and atomically installs a ZIP artifact", async () => {
root = mkdtempSync(join(tmpdir(), "memmy-plugin-artifact-"));
const input = await release();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const MAX_REGISTRY_RESPONSE_BYTES = 1024 * 1024;
const PluginReleaseSchema = z.object({
manifest: PluginManifestSchema,
artifact: z.object({
url: z.string().url(),
url: z.string().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/i)
}).optional()
});
Expand Down Expand Up @@ -43,6 +43,12 @@ export function createHttpPluginRegistry(options: CreateHttpPluginRegistryOption
}
if (!response.ok) throw new Error(`Plugin registry request failed with ${response.status}`);
const release = PluginReleaseSchema.parse(JSON.parse(await readLimitedText(response)));
if (release.artifact) {
const artifactUrl = new URL(release.artifact.url, url);
assertAllowedProtocol(artifactUrl);
if (artifactUrl.username || artifactUrl.password) throw new Error("Plugin artifact URL must not contain credentials");
release.artifact.url = artifactUrl.href;
}
if (release.manifest.id !== pluginId || (version && release.manifest.version !== version)) {
throw Object.assign(new Error("Plugin registry returned a different release"), { code: "plugin_invalid" });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,35 @@ const release = {
};

describe("HttpPluginRegistry", () => {
it("resolves the local registry's relative download URL against its origin", async () => {
const fetchFn = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({
...release, artifact: { ...release.artifact, url: "/api/v1/plugins/com.example.review/artifact?version=1.0.0" }
})));
const registry = createHttpPluginRegistry({ baseUrl: "https://registry.example", fetchFn });
const resolved = await registry.resolve(release.manifest.id);
expect(resolved.artifact?.url).toBe("https://registry.example/api/v1/plugins/com.example.review/artifact?version=1.0.0");
});

it("preserves an OSS signature while authenticating only the resolve request", async () => {
const url = "https://memos-file.oss-cn-shanghai.aliyuncs.com/memmy/legal-plugins/legal.zip?Expires=123&Signature=a%2Bb%3D";
const fetchFn = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({
...release, artifact: { ...release.artifact, url }
})));
const registry = createHttpPluginRegistry({
baseUrl: "https://registry.example", fetchFn, authHeaders: () => ({ authorization: "Bearer test" })
});
expect((await registry.resolve(release.manifest.id)).artifact?.url).toBe(url);
expect(fetchFn.mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: "Bearer test" });
});

it("rejects an insecure storage download URL", async () => {
const fetchFn = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({
...release, artifact: { ...release.artifact, url: "http://storage.example/legal.zip" }
})));
const registry = createHttpPluginRegistry({ baseUrl: "https://registry.example", fetchFn });
await expect(registry.resolve(release.manifest.id)).rejects.toThrow(/HTTPS/);
});

it("resolves a plugin by id and version without accepting a client URL", async () => {
const fetchFn = vi.fn(async () => new Response(JSON.stringify(release), {
headers: { "content-type": "application/json" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface AccountSessionProfileInput extends AccountProfileView {

export interface UpsertAccountSessionInput {
profile: AccountSessionProfileInput;
/** Local edits merge raw fields; cloud refreshes replace them so revoked grants are removed. */
rawProfileMode?: "replace" | "merge";
/** Uuid. */
uuid?: string;
/** Cloud uuid. */
Expand Down Expand Up @@ -192,7 +194,13 @@ export function createAccountSessionRepository(db: DatabaseSync, secretStore: Se
toNullableInteger(input.profile.hasFinishedGuide),
input.profile.region,
registeredAt,
JSON.stringify(buildPersistedRawProfile(previous, input.profile.rawProfile, input.authChannel)),
JSON.stringify(buildPersistedRawProfile(
previous,
input.rawProfileMode === "merge" && previous?.user_id === input.profile.userId
? { ...parseRawProfile(previous.raw_profile_json ?? "{}"), ...input.profile.rawProfile }
: input.profile.rawProfile,
input.authChannel
)),
previous?.cloud_uuid_ref ?? null,
now,
now,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ afterEach(() => {
});

describe("account session repository", () => {
it("isolates grants by account identity even when nicknames match or change", () => {
tempDir = mkdtempSync(join(tmpdir(), "memmy-account-identity-"));
const store = createAppStateStore({ databasePath: join(tempDir, "app.sqlite") });
const repo = store.repositories.accountSession;
const profile = (userId: string, nickname: string, grants?: string[]) => ({
userId, nickname, email: null, phoneNumber: null, avatarUrl: null,
planType: "free", hasFinishedGuide: true, region: null, registeredAt: null,
rawProfile: { id: userId, userName: nickname, ...(grants ? { entitlements: grants } : {}) }
});
try {
repo.upsert({ uuid: "account-a", cloudUuid: "credential-a", profile: profile("user-a", "Same Name", ["plugin:legal-labor"]) });
repo.upsert({ profile: profile("user-a", "Changed Name"), rawProfileMode: "merge" });
expect(repo.getEntitlements()).toEqual(["plugin:legal-labor"]);

repo.upsert({ uuid: "account-b", cloudUuid: "credential-b", profile: profile("user-b", "Changed Name"), rawProfileMode: "merge" });
expect(repo.getEntitlements()).toEqual([]);
expect(repo.activateByCloudUuid("credential-a")).toBe(true);
expect(repo.getEntitlements()).toEqual(["plugin:legal-labor"]);

// Even a reused local row must not merge grants across different cloud user IDs.
repo.upsert({ uuid: "account-a", profile: profile("user-c", "Changed Name"), rawProfileMode: "merge" });
expect(repo.getEntitlements()).toEqual([]);
} finally {
store.close();
}
});
it("tracks send-code timestamps by hashed identifier before login", () => {
tempDir = mkdtempSync(join(tmpdir(), "memmy-account-session-"));
const store = createAppStateStore({ databasePath: join(tempDir, "app.sqlite") });
Expand Down
1 change: 1 addition & 0 deletions App/backend/src/services/account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco
}

const updated = options.accountSessionRepository.upsert({
rawProfileMode: "merge",
profile: {
...session.profile,
nickname: input.nickname,
Expand Down
2 changes: 2 additions & 0 deletions App/backend/src/services/error-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const API_ERROR_CODES = [
"plugin_incompatible",
"plugin_adapter_unsupported",
"plugin_permission_denied",
"plugin_entitlement_required",
"plugin_activation_failed",
"plugin_runtime_error",
"plugin_timeout",
Expand Down Expand Up @@ -60,6 +61,7 @@ export const HTTP_STATUS_BY_CODE: Readonly<Record<ApiErrorCode, number>> = Objec
plugin_incompatible: 400,
plugin_adapter_unsupported: 400,
plugin_permission_denied: 403,
plugin_entitlement_required: 403,
plugin_activation_failed: 409,
plugin_runtime_error: 502,
plugin_timeout: 504,
Expand Down
26 changes: 24 additions & 2 deletions App/backend/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AccountChannel } from "@memmy/local-api-contracts";
import type { AccountChannel, InstalledPlugin } from "@memmy/local-api-contracts";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { resolveCloudClientConfig } from "../config/service-urls.js";
import { resolveCloudClientConfig, resolvePluginRegistryBaseUrl } from "../config/service-urls.js";
import type { AppStateStore } from "../infrastructure/app-state-store/index.js";
import { type MemmyConfigWriter } from "../infrastructure/memmy-config/index.js";
import type { ScanPreferencesStore } from "../infrastructure/memmy-config/agent-access.js";
Expand Down Expand Up @@ -77,6 +77,7 @@ import { createPluginLocalArtifactService } from "./plugin-local-artifact-servic
import { createPluginModelInferenceService } from "./plugin-model-inference-service.js";
import { reconcileEntitledPlugins } from "./plugin-entitlement-reconcile-service.js";
import { createPluginAsrService } from "./plugin-asr-service.js";
import { createPluginAssetKeyService } from "./plugin-asset-key-service.js";
import { createPluginHostServiceRouter } from "./plugin-host-service-router.js";
import { resolvePluginModelSelection } from "./plugin-model-policy.js";

Expand Down Expand Up @@ -192,6 +193,12 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba
});
const pluginHostServices = options.pluginHostServices ?? createPluginHostServiceRouter([
{ services: ["model-inference", "embedding-inference"], invoker: pluginModelInference },
{ services: ["asset-key"], invoker: createPluginAssetKeyService({
baseUrl: resolveCloudClientConfig(process.env).baseUrl,
getPlugin: (id) => pluginRepository.get(id),
authHeaders: pluginRegistryAuthHeaders,
isEntitlementGranted
}) },
{
services: ["asr"],
invoker: createPluginAsrService({ asr: asrService, audioRoots: pluginFileInputRoots })
Expand Down Expand Up @@ -219,6 +226,7 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba
artifactManager: options.pluginArtifactManager ?? createPluginArtifactManager({
installRoot: join(dirname(options.appStateStore.databasePath), "plugins"),
trustedLocalRoots: options.trustedBundledPluginRoots,
authOrigin: resolvePluginRegistryBaseUrl(process.env),
authHeaders: pluginRegistryAuthHeaders
}),
skillManager: createPluginSkillManager({ skillsRoot: join(resolveAgentWorkspace(process.env), "skills") }),
Expand Down Expand Up @@ -315,13 +323,22 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba
memoryClient: options.memoryClient,
accountChannel: options.accountChannel,
onAccountGrantsRefreshed: async () => {
const previousCapabilities = activePluginCapabilities(plugins.list());
const failures = await reconcileEntitledPlugins({
plugins,
entitlements: accountSessionRepository.getEntitlements()
});
for (const failure of failures) {
console.warn(`Entitled plugin reconciliation failed for ${failure.pluginId}: ${failure.message}`);
}
if (previousCapabilities !== activePluginCapabilities(plugins.list())) {
try {
const result = await memmyAgentAdminClient.reloadMcpConfig();
if (!result.ok) console.warn(`Agent plugin tool reload did not complete: ${result.message}`);
} catch (error) {
console.warn(`Agent plugin tool reload unavailable: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
}),
integrations: createIntegrationService({
Expand Down Expand Up @@ -449,6 +466,11 @@ function agentApiType(protocol: string): "auto" | "chatCompletions" | "responses
return "auto";
}

function activePluginCapabilities(plugins: InstalledPlugin[]): string {
return JSON.stringify(plugins.filter((plugin) => plugin.state === "active")
.map((plugin) => [plugin.id, plugin.version]).sort((a, b) => a[0]!.localeCompare(b[0]!)));
}

function createUnavailableMemmyConfigWriter(): MemmyConfigWriter {
const unavailable = () => {
throw new Error("Memmy config writer is not configured");
Expand Down
Loading
Loading