diff --git a/.gitignore b/.gitignore index 7fc76aee1..316ecd84d 100644 --- a/.gitignore +++ b/.gitignore @@ -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软件打包/ diff --git a/App/backend/local-api-contracts/src/memory-runtime.ts b/App/backend/local-api-contracts/src/memory-runtime.ts index 409274ab0..464cb1161 100644 --- a/App/backend/local-api-contracts/src/memory-runtime.ts +++ b/App/backend/local-api-contracts/src/memory-runtime.ts @@ -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; diff --git a/App/backend/src/adapters/inbound/local-api/routes/plugins.ts b/App/backend/src/adapters/inbound/local-api/routes/plugins.ts index 73dae0366..612f382a9 100644 --- a/App/backend/src/adapters/inbound/local-api/routes/plugins.ts +++ b/App/backend/src/adapters/inbound/local-api/routes/plugins.ts @@ -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 }); }) ); diff --git a/App/backend/src/adapters/inbound/local-api/server.ts b/App/backend/src/adapters/inbound/local-api/server.ts index 3cf1053e2..cdc9dc7f1 100644 --- a/App/backend/src/adapters/inbound/local-api/server.ts +++ b/App/backend/src/adapters/inbound/local-api/server.ts @@ -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. diff --git a/App/backend/src/adapters/inbound/local-api/tests/plugin-sse-recovery.test.ts b/App/backend/src/adapters/inbound/local-api/tests/plugin-sse-recovery.test.ts new file mode 100644 index 000000000..1e115e398 --- /dev/null +++ b/App/backend/src/adapters/inbound/local-api/tests/plugin-sse-recovery.test.ts @@ -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(); } +}); diff --git a/App/backend/src/adapters/outbound/plugin-artifact/index.ts b/App/backend/src/adapters/outbound/plugin-artifact/index.ts index 7b289d00c..7a821b127 100644 --- a/App/backend/src/adapters/outbound/plugin-artifact/index.ts +++ b/App/backend/src/adapters/outbound/plugin-artifact/index.ts @@ -34,19 +34,27 @@ export interface CreatePluginArtifactManagerOptions { trustedLocalRoots?: readonly string[]; /** Supplies account credentials so a registry can gate entitlement-restricted downloads. */ authHeaders?: () => Record; + /** 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" }); diff --git a/App/backend/src/adapters/outbound/plugin-artifact/tests/plugin-artifact.test.ts b/App/backend/src/adapters/outbound/plugin-artifact/tests/plugin-artifact.test.ts index 936e0ae14..f436fa8f9 100644 --- a/App/backend/src/adapters/outbound/plugin-artifact/tests/plugin-artifact.test.ts +++ b/App/backend/src/adapters/outbound/plugin-artifact/tests/plugin-artifact.test.ts @@ -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(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(); diff --git a/App/backend/src/adapters/outbound/plugin-registry/http-plugin-registry.ts b/App/backend/src/adapters/outbound/plugin-registry/http-plugin-registry.ts index c924d568b..c40ac240e 100644 --- a/App/backend/src/adapters/outbound/plugin-registry/http-plugin-registry.ts +++ b/App/backend/src/adapters/outbound/plugin-registry/http-plugin-registry.ts @@ -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() }); @@ -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" }); } diff --git a/App/backend/src/adapters/outbound/plugin-registry/tests/http-plugin-registry.test.ts b/App/backend/src/adapters/outbound/plugin-registry/tests/http-plugin-registry.test.ts index c1a6d4135..6b516dce1 100644 --- a/App/backend/src/adapters/outbound/plugin-registry/tests/http-plugin-registry.test.ts +++ b/App/backend/src/adapters/outbound/plugin-registry/tests/http-plugin-registry.test.ts @@ -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(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(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(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" } diff --git a/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts b/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts index 6a95085dd..da85e06d8 100644 --- a/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts +++ b/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts @@ -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. */ @@ -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, diff --git a/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts b/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts index 3f8587b3b..6cc06fef7 100644 --- a/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts +++ b/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts @@ -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") }); diff --git a/App/backend/src/services/account-service.ts b/App/backend/src/services/account-service.ts index 27dfa6626..2c9190212 100644 --- a/App/backend/src/services/account-service.ts +++ b/App/backend/src/services/account-service.ts @@ -181,6 +181,7 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco } const updated = options.accountSessionRepository.upsert({ + rawProfileMode: "merge", profile: { ...session.profile, nickname: input.nickname, diff --git a/App/backend/src/services/error-envelope.ts b/App/backend/src/services/error-envelope.ts index 7648c7e2e..c2166e197 100644 --- a/App/backend/src/services/error-envelope.ts +++ b/App/backend/src/services/error-envelope.ts @@ -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", @@ -60,6 +61,7 @@ export const HTTP_STATUS_BY_CODE: Readonly> = 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, diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index 489135b40..1af576261 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -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"; @@ -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"; @@ -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 }) @@ -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") }), @@ -315,6 +323,7 @@ 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() @@ -322,6 +331,14 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba 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({ @@ -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"); diff --git a/App/backend/src/services/plugin-asset-key-service.ts b/App/backend/src/services/plugin-asset-key-service.ts new file mode 100644 index 000000000..625161328 --- /dev/null +++ b/App/backend/src/services/plugin-asset-key-service.ts @@ -0,0 +1,55 @@ +/** Authorizes encrypted plugin assets through the account cloud; keys never persist. */ +import { readFile, realpath, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import type { PluginHostServiceInvoker, PluginRuntimeRecord } from "../adapters/outbound/plugin-runtime/index.js"; + +const Input = z.object({ assetPath: z.string().regex(/^assets\/[a-zA-Z0-9._-]+$/) }); +const Asset = z.object({ file: z.string(), keyId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), sha256: z.string().regex(/^[a-f0-9]{64}$/), wrappedKey: z.string().min(64).max(2048) }); +const Index = z.object({ format: z.literal("memmy-asset-seal-v1"), pluginId: z.string(), version: z.string(), assets: z.record(z.string(), Asset) }); +const Key = z.object({ key: z.string().regex(/^[A-Za-z0-9+/]{43}=$/) }); +export function createPluginAssetKeyService(options: { + baseUrl: string; + getPlugin(id: string): PluginRuntimeRecord | null; + authHeaders(): Record; + isEntitlementGranted(entitlement: string): boolean; + fetch?: typeof fetch; +}): PluginHostServiceInvoker { + const base = new URL(options.baseUrl); + if (base.protocol !== "https:" && !(base.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))) throw new Error("Asset key service requires HTTPS"); + return { async invoke(call) { + call.signal?.throwIfAborted(); + if (call.service !== "asset-key") throw failure("host_service_unavailable"); + const input = Input.parse(call.input); + const plugin = options.getPlugin(call.pluginId); + if (!plugin?.rootPath || plugin.state !== "active" || !plugin.manifest.requiredEntitlement + || !options.isEntitlementGranted(plugin.manifest.requiredEntitlement)) throw failure("plugin_entitlement_required"); + const headers = options.authHeaders(); + if (!headers.authorization) throw failure("plugin_entitlement_required"); + const root = await realpath(plugin.rootPath); + const indexPath = join(root, "asset-seal.json"); + if (await realpath(indexPath) !== indexPath || (await stat(indexPath)).size > 64 * 1024) throw failure("plugin_invalid"); + const index = Index.parse(JSON.parse(await readFile(indexPath, "utf8"))); + const asset = index.assets[input.assetPath]; + if (index.pluginId !== plugin.id || index.version !== plugin.version || !asset || asset.file !== input.assetPath + ".enc") throw failure("plugin_invalid"); + const timeout = AbortSignal.timeout(15_000); + const response = await (options.fetch ?? fetch)(new URL(`/api/v1/plugins/${encodeURIComponent(plugin.id)}/asset-key`, base), { + method: "POST", headers: { ...headers, "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ version: plugin.version, assetPath: input.assetPath, keyId: asset.keyId, sha256: asset.sha256, wrappedKey: asset.wrappedKey }), + redirect: "error", signal: call.signal ? AbortSignal.any([call.signal, timeout]) : timeout + }); + if (response.status === 401 || response.status === 403) throw failure("plugin_entitlement_required"); + if (!response.ok) throw failure("asset_key_unavailable"); + let text = ""; + if (!response.body) throw failure("asset_key_unavailable"); + for await (const chunk of response.body) { + text += Buffer.from(chunk).toString("utf8"); + if (Buffer.byteLength(text) > 2048) throw failure("asset_key_unavailable"); + } + try { return Key.parse(JSON.parse(text)); } + catch { throw failure("asset_key_unavailable"); } + } }; +} +function failure(code: string): Error { + return Object.assign(new Error(code === "plugin_entitlement_required" ? "Plugin asset access requires account authorization" : "Plugin asset key unavailable"), { code, retryable: false }); +} diff --git a/App/backend/src/services/progress-bus.ts b/App/backend/src/services/progress-bus.ts index 0e5992768..9687bdd65 100644 --- a/App/backend/src/services/progress-bus.ts +++ b/App/backend/src/services/progress-bus.ts @@ -28,6 +28,8 @@ export interface ProgressBusEventMap { /** Contract for progress bus. */ export interface ProgressBus { + pendingPluginEvents(): PluginCapabilityEventPayload[]; + acknowledgePluginInteraction(pluginId: string, callId: string, interactionId: string): void; emit( eventName: EventName, event: ProgressBusEventMap[EventName] @@ -41,9 +43,33 @@ export interface ProgressBus { /** Creates create progress bus. */ export function createProgressBus(): ProgressBus { const emitter = new EventEmitter(); + // Reconnecting a renderer must not lose the only card a running tool awaits. + // Keep only live calls; terminal calls and acknowledged prompts are retired. + const pluginCalls = new Map(); return { + pendingPluginEvents() { return [...pluginCalls.values()].flat(); }, + acknowledgePluginInteraction(pluginId, callId, interactionId) { + const key = `${pluginId}:${callId}`; + const events = pluginCalls.get(key); + if (events) pluginCalls.set(key, events.filter(({ event }) => event.type !== "interaction" || event.request.interactionId !== interactionId)); + }, emit(eventName, event) { + if (eventName === "plugin.capability_event") { + const payload = event as PluginCapabilityEventPayload; + const key = `${payload.pluginId}:${payload.callId}`; + if (payload.event.type === "result" || payload.event.type === "error") pluginCalls.delete(key); + else { + const retained = pluginCalls.get(key) ?? []; + const next = payload.event; + pluginCalls.set(key, [...retained.filter(({ event: prior }) => { + if (next.type === "progress" || next.type === "task-list") return prior.type !== next.type; + if (next.type === "interaction") return prior.type !== "interaction" || prior.request.interactionId !== next.request.interactionId; + if (next.type === "artifact") return prior.type !== "artifact" || prior.artifact.id !== next.artifact.id; + return true; + }), payload]); + } + } emitter.emit(eventName, event); }, diff --git a/App/backend/src/services/tests/account-plugin-integration.test.ts b/App/backend/src/services/tests/account-plugin-integration.test.ts new file mode 100644 index 000000000..8aaeca32e --- /dev/null +++ b/App/backend/src/services/tests/account-plugin-integration.test.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { createBackendServices } from "../index.js"; +import { createAppStateStore } from "../../infrastructure/app-state-store/index.js"; +import { createPermissionManager } from "../../permission/index.js"; +import { createDefaultAgentAdapterRegistry } from "../../adapters/outbound/agent-adapter/index.js"; +import { createInMemoryPluginRegistry } from "../../adapters/outbound/plugin-registry/index.js"; +import { createMockCloudClient } from "../../tests/support/mock-cloud-client.js"; +import { createMockMemoryClient } from "../../tests/support/mock-memory-client.js"; + +afterEach(() => vi.unstubAllEnvs()); + +it("refreshes Agent tools when account grants install or disable a plugin, and keeps nickname edits usable", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-account-plugin-")); + vi.stubEnv("MEMMY_AGENT_WORKSPACE", join(root, "workspace")); + vi.stubEnv("MEMMY_AGENT_DATA_DIR", root); + const store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + const cloud = createMockCloudClient(); + const login = await cloud.login({ channel: "phone", phoneNumber: "13800000000", verificationCode: "123456", loginSource: "Memmy" }); + store.repositories.accountSession.upsert({ + uuid: login.accountUuid, cloudUuid: login.uuid, profile: login.profile, authChannel: "phone" + }); + let grants = ["plugin:legal-labor"]; + const reloadMcpConfig = vi.fn(async () => ({ ok: true, message: "refreshed", requires_restart: false })); + const services = createBackendServices({ + appStateStore: store, + agentAdapterRegistry: createDefaultAgentAdapterRegistry(), + permissionManager: createPermissionManager({ appStateStore: store }), + memoryClient: createMockMemoryClient(), + memmyConfigPath: join(root, "config.yaml"), + cloudClient: { + ...cloud, + async getAccountInfo(input) { + const profile = await cloud.getAccountInfo(input); + return { ...profile, entitlements: grants, rawProfile: { ...profile.rawProfile, entitlements: grants } }; + } + }, + memmyAgentAdminClient: { + getChannelDefinitions: async () => ({ channels: [] }), + getChannelConnections: async () => ({ connections: [] }), + configureChannel: async () => ({ status: "connected", running: true }), + stopChannel: async () => ({ status: "disabled", running: false }), + startWeixinLogin: async () => ({ status: "pendingQr" }), + pollWeixinLogin: async () => ({ status: "connected" }), + startFeishuLogin: async () => ({ status: "pendingQr" }), + pollFeishuLogin: async () => ({ status: "connected" }), + reloadMcpConfig + }, + pluginRegistry: createInMemoryPluginRegistry([{ + manifest: { + apiVersion: "memmy/v1", id: "legal-labor", name: "Legal Test", version: "0.1.0", + requiredEntitlement: "plugin:legal-labor", + runtime: { adapter: "command", config: {} }, permissions: [], + capabilities: [{ id: "session-start", name: "Start", description: "Start diagnosis", + inputSchema: { type: "object" }, outputSchema: { type: "object" }, execution: "request" }] + } + }]), + pluginArtifactManager: { + install: async () => ({ rootPath: null, artifactHash: null }), + readTextFile: async () => "", remove: async () => undefined + }, + pluginRuntimeHost: { + supports: () => true, activate: async () => undefined, deactivate: async () => undefined, + async *invoke() { yield { type: "result", output: { ok: true } }; }, + cancel: async () => undefined, respond: async () => undefined + } + }); + try { + await services.account.getSession(); + expect(services.plugins.get("legal-labor").state).toBe("active"); + expect(reloadMcpConfig).toHaveBeenCalledTimes(1); + await services.account.updateProfile({ nickname: "Legal Test" }); + const events = []; + for await (const event of services.plugins.invoke({ + callId: "test-call", pluginId: "legal-labor", capabilityId: "session-start", conversationId: "test-chat", input: {} + })) events.push(event); + expect(events).toEqual([{ type: "result", output: { ok: true } }]); + await services.account.getSession(); + expect(reloadMcpConfig).toHaveBeenCalledTimes(1); + grants = []; + await services.account.getSession(); + expect(services.plugins.get("legal-labor").state).toBe("disabled"); + expect(reloadMcpConfig).toHaveBeenCalledTimes(2); + } finally { + await services.plugins.shutdown(); + store.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/App/backend/src/services/tests/account-service.test.ts b/App/backend/src/services/tests/account-service.test.ts index 95d1dc4c8..f6040dbdb 100644 --- a/App/backend/src/services/tests/account-service.test.ts +++ b/App/backend/src/services/tests/account-service.test.ts @@ -28,6 +28,37 @@ function createAccountService(options: TestAccountServiceOptions) { } describe("AccountService", () => { + it("keeps cloud grants across nickname edits but removes them on authoritative cloud refresh", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-account-grants-")); + const store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + let grants = ["plugin:legal-labor"]; + const profile = cloudProfile(); + store.repositories.accountSession.upsert({ + uuid: "test-account", cloudUuid: "test-credential", profile + }); + const service = createAccountService({ + accountSessionRepository: store.repositories.accountSession, + cloudClient: { + ...createCloudClientStub(), + async getAccountInfo() { + return { ...profile, rawProfile: { ...profile.rawProfile, entitlements: grants } }; + } + } + }); + try { + await service.getSession(); + expect(store.repositories.accountSession.getEntitlements()).toEqual(["plugin:legal-labor"]); + await service.updateProfile({ nickname: "Legal Test" }); + expect(store.repositories.accountSession.getEntitlements()).toEqual(["plugin:legal-labor"]); + expect(store.repositories.accountSession.get()).toMatchObject({ profile: { nickname: "Legal Test" } }); + grants = []; + await service.getSession(); + expect(store.repositories.accountSession.getEntitlements()).toEqual([]); + } finally { + store.close(); + rmSync(root, { recursive: true, force: true }); + } + }); it("treats a logged-out lottery reward lookup as no reward", async () => { let cloudCalls = 0; const service = createAccountService({ diff --git a/App/backend/src/services/tests/plugin-asset-key-service.test.ts b/App/backend/src/services/tests/plugin-asset-key-service.test.ts new file mode 100644 index 000000000..8c4074f41 --- /dev/null +++ b/App/backend/src/services/tests/plugin-asset-key-service.test.ts @@ -0,0 +1,64 @@ +import { mkdtempSync, writeFileSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntimeRecord } from "../../adapters/outbound/plugin-runtime/index.js"; +import { createPluginAssetKeyService } from "../plugin-asset-key-service.js"; + +describe("plugin asset key authorization", () => { + let root: string; + const asset = { file: "assets/template.docx.enc", keyId: "test-key", sha256: "a".repeat(64), wrappedKey: "A".repeat(512) }; + const index = { format: "memmy-asset-seal-v1", pluginId: "legal-labor", version: "0.1.0-test", assets: { "assets/template.docx": asset } }; + const call = { pluginId: "legal-labor", callId: "call", conversationId: "conversation", service: "asset-key", input: { assetPath: "assets/template.docx" } }; + const key = Buffer.alloc(32, 7).toString("base64"); + beforeEach(() => { root = mkdtempSync(join(tmpdir(), "memmy-asset-service-")); writeFileSync(join(root, "asset-seal.json"), JSON.stringify(index)); }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + function setup(status = 200) { + const plugin = { id: "legal-labor", version: "0.1.0-test", state: "active", rootPath: root, manifest: { requiredEntitlement: "plugin:legal-labor" } } as PluginRuntimeRecord; + const fetch = vi.fn(async () => new Response(JSON.stringify({ key }), { status })); + const authHeaders = vi.fn(() => ({ authorization: "Bearer account-credential" })); + const isEntitlementGranted = vi.fn(() => true); + const service = createPluginAssetKeyService({ baseUrl: "https://test-api.memmy.cn", getPlugin: () => plugin, authHeaders, isEntitlementGranted, fetch }); + return { service, fetch, plugin, authHeaders, isEntitlementGranted }; + } + it("uses installed metadata and account credentials instead of caller-supplied envelope", async () => { + const s = setup(); + await expect(s.service.invoke({...call,input:{...call.input,wrappedKey:"forged"}})).resolves.toEqual({key}); + const [url, request] = s.fetch.mock.calls[0]!; + expect(String(url)).toBe("https://test-api.memmy.cn/api/v1/plugins/legal-labor/asset-key"); + expect(JSON.parse(request!.body as string)).toEqual({version:index.version,assetPath:"assets/template.docx",keyId:asset.keyId,sha256:asset.sha256,wrappedKey:asset.wrappedKey}); + expect(request!.redirect).toBe("error"); + }); + it.each([401,403])("honors cloud authorization failure HTTP %s despite stale local grants", async (status) => { + await expect(setup(status).service.invoke(call)).rejects.toMatchObject({code:"plugin_entitlement_required"}); + }); + it("refuses revoked local grants, disabled plugin and missing credentials before networking", async () => { + for (const mutate of [(s:ReturnType)=>s.isEntitlementGranted.mockReturnValue(false), + (s:ReturnType)=>{s.plugin.state="disabled";}, + (s:ReturnType)=>s.authHeaders.mockReturnValue({authorization:""})]) { + const s=setup();mutate(s);await expect(s.service.invoke(call)).rejects.toMatchObject({code:"plugin_entitlement_required"});expect(s.fetch).not.toHaveBeenCalled(); + } + }); + it("rejects unknown asset, traversal, and mismatched releases", async () => { + const s=setup(); + await expect(s.service.invoke({...call,input:{assetPath:"assets/other.docx"}})).rejects.toMatchObject({code:"plugin_invalid"}); + await expect(s.service.invoke({...call,input:{assetPath:"assets/../../private"}})).rejects.toThrow(); + writeFileSync(join(root,"asset-seal.json"),JSON.stringify({...index,version:"other"})); + await expect(s.service.invoke(call)).rejects.toMatchObject({code:"plugin_invalid"});expect(s.fetch).not.toHaveBeenCalled(); + }); + it("rejects symlinked metadata", async () => { + const s=setup();writeFileSync(join(root,"outside.json"),JSON.stringify(index));rmSync(join(root,"asset-seal.json"));symlinkSync(join(root,"outside.json"),join(root,"asset-seal.json")); + await expect(s.service.invoke(call)).rejects.toMatchObject({code:"plugin_invalid"});expect(s.fetch).not.toHaveBeenCalled(); + }); + it("rejects malformed, oversized and unavailable key responses", async () => { + const s=setup(); + for (const body of [JSON.stringify({key:"short"}),"x".repeat(3000),"not-json"]) { + s.fetch.mockImplementation(async()=>new Response(body));await expect(s.service.invoke(call)).rejects.toMatchObject({code:"asset_key_unavailable"}); + } + await expect(setup(503).service.invoke(call)).rejects.toMatchObject({code:"asset_key_unavailable"}); + }); + it("does not forward cancelled requests", async () => { + const s=setup();const controller=new AbortController();controller.abort(); + await expect(s.service.invoke({...call,signal:controller.signal})).rejects.toThrow();expect(s.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/App/backend/src/services/tests/plugin-service.test.ts b/App/backend/src/services/tests/plugin-service.test.ts index 7256a9227..e13e7307d 100644 --- a/App/backend/src/services/tests/plugin-service.test.ts +++ b/App/backend/src/services/tests/plugin-service.test.ts @@ -94,6 +94,16 @@ function createContext( } describe("PluginService", () => { + it("does not download or reinstall an unchanged installed release on account refresh", async () => { + const { service, artifactManager } = createContext(); + await service.install(release.manifest.id); + vi.mocked(artifactManager.install).mockClear(); + await service.update(release.manifest.id); + await service.update(release.manifest.id); + expect(artifactManager.install).not.toHaveBeenCalled(); + expect(service.list()).toHaveLength(1); + }); + it("loads a declared renderer from the installed plugin artifact", async () => { const rendererRelease: PluginRelease = { manifest: { ...release.manifest, ui: { renderer: { entry: "ui/index.html", capabilities: ["run"] } } } diff --git a/App/backend/src/services/tests/progress-bus.test.ts b/App/backend/src/services/tests/progress-bus.test.ts index ecaf27044..5883c5edd 100644 --- a/App/backend/src/services/tests/progress-bus.test.ts +++ b/App/backend/src/services/tests/progress-bus.test.ts @@ -3,6 +3,19 @@ import { describe, expect, it } from "vitest"; import { createProgressBus } from "../progress-bus.js"; describe("progress bus", () => { + it("restores live plugin cards after reconnect without resurrecting answered steps", () => { + const bus = createProgressBus(); + const call = { pluginId: "legal-labor", capabilityId: "session-start", callId: "call-1", conversationId: "websocket:chat-1", origin: "agent" as const }; + bus.emit("plugin.capability_event", { ...call, event: { type: "progress", current: 0, total: 2 } }); + bus.emit("plugin.capability_event", { ...call, event: { type: "interaction", request: { type: "question", interactionId: "guide", payload: {} } } }); + bus.emit("plugin.capability_event", { ...call, event: { type: "progress", current: 1, total: 2 } }); + expect(bus.pendingPluginEvents()).toHaveLength(2); + bus.acknowledgePluginInteraction(call.pluginId, call.callId, "guide"); + bus.emit("plugin.capability_event", { ...call, event: { type: "interaction", request: { type: "file-input", interactionId: "upload", payload: {} } } }); + expect(bus.pendingPluginEvents().filter(({ event }) => event.type === "interaction").map(({ event }) => event.type === "interaction" && event.request.interactionId)).toEqual(["upload"]); + bus.emit("plugin.capability_event", { ...call, event: { type: "result", output: { sessionId: "legal-session" } } }); + expect(bus.pendingPluginEvents()).toEqual([]); + }); it("emits typed scan progress and supports unsubscribe", () => { const bus = createProgressBus(); const received: string[] = []; diff --git a/App/frontend/desktop/src/api/memmy-agent-client.ts b/App/frontend/desktop/src/api/memmy-agent-client.ts index 1c9fb26e9..5cb01d6e8 100644 --- a/App/frontend/desktop/src/api/memmy-agent-client.ts +++ b/App/frontend/desktop/src/api/memmy-agent-client.ts @@ -557,7 +557,7 @@ const UploadedAgentAudioSchema = z.object({ bytes: z.number() }); -const UploadedAgentMediaSchema = z.discriminatedUnion("kind", [ +export const UploadedAgentMediaSchema = z.discriminatedUnion("kind", [ UploadedAgentImageSchema, UploadedAgentFileSchema, UploadedAgentAudioSchema diff --git a/App/frontend/desktop/src/api/tests/http.test.ts b/App/frontend/desktop/src/api/tests/http.test.ts index 392051d99..b9978f16a 100644 --- a/App/frontend/desktop/src/api/tests/http.test.ts +++ b/App/frontend/desktop/src/api/tests/http.test.ts @@ -10,6 +10,15 @@ const runtimeConfig = { }; describe("requestJson", () => { + it("preserves a plugin entitlement denial instead of turning it into an unknown error", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + error: { code: "plugin_entitlement_required", message: "Plugin grant missing", requestId: "plugin-test" } + }), { status: 403, headers: { "content-type": "application/json" } }))); + await expect(requestJson({ config: runtimeConfig, path: "/api/v1/plugins/legal-labor/capabilities/session-start/invoke", + schema: z.object({ ok: z.literal(true) }), body: {} })).rejects.toMatchObject({ + status: 403, code: "plugin_entitlement_required", requestId: "plugin-test" + }); + }); afterEach(() => { vi.unstubAllGlobals(); }); diff --git a/App/frontend/desktop/src/app/plugin-ui-context.tsx b/App/frontend/desktop/src/app/plugin-ui-context.tsx index 05d8f9537..c5062d224 100644 --- a/App/frontend/desktop/src/app/plugin-ui-context.tsx +++ b/App/frontend/desktop/src/app/plugin-ui-context.tsx @@ -6,7 +6,9 @@ import { useEffect, useRef, useState, - type ReactNode + type ReactNode, + type Dispatch, + type SetStateAction } from "react"; import type { CapabilityEvent, PluginCapabilityEventPayload, PluginInvocationOrigin } from "@memmy/local-api-contracts"; @@ -36,6 +38,15 @@ interface PluginUiContextValue { registerChatFeedback(conversationId: string, handler: (feedback: PluginChatFeedback) => boolean): () => void; routeChatFeedback(chatId: string, feedback: PluginChatFeedback): boolean; fileDrafts: Map; + /** Card decisions outlive route pages, just like the calls they belong to. */ + hiddenCallIds: Set; + setHiddenCallIds: Dispatch>>; + hiddenInteractionSteps: Map; + dismissedCallIds: Set; + setDismissedCallIds: Dispatch>>; + answeredInteractions: Set; + setAnsweredInteractions: Dispatch>>; + openingCommands: Set; calls: PluginUiCall[]; activeSurface: PluginInvocationContext | null; openSurface(context: PluginInvocationContext): void; @@ -49,6 +60,11 @@ const PluginUiContext = createContext(null); export function PluginUiProvider(props: { children: ReactNode }) { const feedbackHandlers = useRef(new Map boolean }>()); const fileDrafts = useRef(new Map()).current; + const [hiddenCallIds, setHiddenCallIds] = useState>(() => new Set()); + const hiddenInteractionSteps = useRef(new Map()).current; + const [dismissedCallIds, setDismissedCallIds] = useState>(() => new Set()); + const [answeredInteractions, setAnsweredInteractions] = useState>(() => new Set()); + const openingCommands = useRef(new Set()).current; const registerChatFeedback = useCallback((conversationId: string, handler: (feedback: PluginChatFeedback) => boolean) => { const id = Symbol(); feedbackHandlers.current.set(id, { conversationId, handler }); @@ -70,7 +86,11 @@ export function PluginUiProvider(props: { children: ReactNode }) { }, []); const openSurface = useCallback((context: PluginInvocationContext) => setActiveSurface(context), []); const closeSurface = useCallback(() => setActiveSurface(null), []); - const value = useMemo(() => ({ calls, activeSurface, openSurface, closeSurface, receive, registerChatFeedback, routeChatFeedback, fileDrafts }), [activeSurface, calls, closeSurface, openSurface, receive, registerChatFeedback, routeChatFeedback, fileDrafts]); + const value = useMemo(() => ({ calls, activeSurface, openSurface, closeSurface, receive, registerChatFeedback, routeChatFeedback, fileDrafts, + hiddenCallIds, setHiddenCallIds, hiddenInteractionSteps, dismissedCallIds, setDismissedCallIds, + answeredInteractions, setAnsweredInteractions, openingCommands + }), [activeSurface, calls, closeSurface, openSurface, receive, registerChatFeedback, routeChatFeedback, fileDrafts, + hiddenCallIds, hiddenInteractionSteps, dismissedCallIds, answeredInteractions, openingCommands]); return {props.children}; } @@ -93,7 +113,11 @@ export function reducePluginUiCalls( conversationId: payload.conversationId, origin: payload.origin, events: [payload.event] - }].slice(-MAX_PLUGIN_CALLS); + }].filter((call, position, all) => { + // Prune completed history, never the sole UI for a still waiting tool. + if (!call.events.some((event) => event.type === "result" || event.type === "error")) return true; + return position >= all.length - MAX_PLUGIN_CALLS; + }); } const current = calls[index]!; const next = [...calls]; diff --git a/App/frontend/desktop/src/app/tests/plugin-ui-context.test.tsx b/App/frontend/desktop/src/app/tests/plugin-ui-context.test.tsx index 41c8a6772..367cb69ce 100644 --- a/App/frontend/desktop/src/app/tests/plugin-ui-context.test.tsx +++ b/App/frontend/desktop/src/app/tests/plugin-ui-context.test.tsx @@ -3,11 +3,23 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { PluginUiProvider, usePluginUi } from "../plugin-ui-context.js"; +import { PluginUiProvider, usePluginUi, reducePluginUiCalls, type PluginUiCall } from "../plugin-ui-context.js"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; describe("PluginUiProvider", () => { + it("prunes completed history without losing an older pending confirmation", () => { + const pending: PluginUiCall = { pluginId: "legal-labor", capabilityId: "session-start", callId: "waiting", conversationId: "chat-1", origin: "agent", events: [ + { type: "interaction", request: { interactionId: "upload", type: "file-input", payload: {} } } + ] }; + let calls = [pending]; + for (let index = 0; index < 80; index++) { + calls = reducePluginUiCalls(calls, { ...pending, callId: `done-${index}`, event: { type: "result", output: {} } }); + } + expect(calls[0]?.callId).toBe(pending.callId); + expect(calls.length).toBeLessThanOrEqual(51); + expect(calls.at(-1)?.callId).toBe("done-79"); + }); let container: HTMLDivElement; let root: Root; diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index b352b650b..8dc3175c2 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -83,6 +83,9 @@ export const zhCNMessages = { "plugin.ui.cancelFailed": "取消失败,请重试", "plugin.ui.chooseFiles": "选择文件", "plugin.ui.upload": "上传", + "plugin.ui.uploadFiles": "上传文件", + "plugin.ui.generateDiagnosis": "生成 AI 诊断报告", + "plugin.ui.diagnosisSaved": "诊断材料已保存,无需重新上传。可以重试生成报告;如有任务等待卡片确认,请先完成或停止该任务。", "plugin.ui.skip": "跳过", "plugin.ui.noFiles": "尚未选择文件", "plugin.ui.selectedFiles": "已选择的文件", @@ -738,6 +741,7 @@ export const zhCNMessages = { "home.agent.executionInterrupted": "上次执行已中断,可重新发送", "home.agent.recoveryTimeout": "连接恢复超时或消息未被服务端记录", "home.error.generic": "操作未完成,请重试", + "home.error.pluginEntitlementRequired": "当前账号未获得此插件的使用授权,请刷新账号状态后重试。", "home.error.network": "连接失败,请稍后重试", "home.error.timeout": "请求超时,请重试", "home.error.projectUnavailable": "项目文件夹暂时不可用", @@ -2023,6 +2027,9 @@ export const enUSMessages: Record = { "plugin.ui.cancelFailed": "Cancellation failed. Try again.", "plugin.ui.chooseFiles": "Choose files", "plugin.ui.upload": "Upload", + "plugin.ui.uploadFiles": "Upload files", + "plugin.ui.generateDiagnosis": "Generate AI diagnosis report", + "plugin.ui.diagnosisSaved": "Diagnosis materials are saved. Retry without uploading again. If another task is waiting for confirmation, finish or stop it first.", "plugin.ui.skip": "Skip", "plugin.ui.noFiles": "No files selected", "plugin.ui.selectedFiles": "Selected files", @@ -2661,6 +2668,7 @@ export const enUSMessages: Record = { "home.agent.executionInterrupted": "The previous run was interrupted. You can send it again", "home.agent.recoveryTimeout": "Connection recovery timed out or the message was not recorded", "home.error.generic": "The operation did not complete. Please try again.", + "home.error.pluginEntitlementRequired": "This account does not have access to this plugin. Refresh your account status and try again.", "home.error.network": "Connection failed. Please try again later.", "home.error.timeout": "The request timed out. Please try again.", "home.error.projectUnavailable": "The project folder is temporarily unavailable.", diff --git a/App/frontend/desktop/src/lib/recording-archive.ts b/App/frontend/desktop/src/lib/recording-archive.ts new file mode 100644 index 000000000..41b9df556 --- /dev/null +++ b/App/frontend/desktop/src/lib/recording-archive.ts @@ -0,0 +1,57 @@ +import { useSyncExternalStore } from "react"; +import { z } from "zod"; +import { UploadedAgentMediaSchema } from "../api/memmy-agent-client.js"; +import type { RecordingArchiveEntry } from "../pages/recording-entry.js"; + +const schema = z.object({ + id: z.string(), title: z.string(), recordedAt: z.string(), durationMs: z.number(), speakerCount: z.number(), + audioPath: z.string(), sessionKey: z.string().optional(), + files: z.array(UploadedAgentMediaSchema).optional(), + transcript: z.object({ text: z.string(), segments: z.array(z.object({ + text: z.string(), speakerId: z.number().optional(), startMs: z.number().optional(), endMs: z.number().optional() + })) }), + status: z.enum(["ready", "failed", "transcribing"]).optional() +}); +const empty: RecordingArchiveEntry[] = []; +const snapshots = new Map(); +const listeners = new Map void>>(); +const key = (scope: string) => `memmy.recordingArchive.v1:${scope}`; + +export function readRecordingArchive(scope: string): RecordingArchiveEntry[] { + const cached = snapshots.get(scope); + if (cached) return cached; + let entries = empty; + try { + const parsed = z.array(schema).safeParse(JSON.parse(window.localStorage.getItem(key(scope)) ?? "[]")); + if (parsed.success) entries = parsed.data.map((entry) => ({ ...entry, audioUrl: null })); + } catch { /* A damaged or unavailable storage entry must not break the chat. */ } + snapshots.set(scope, entries); + return entries; +} + +export function saveRecordingArchiveEntry(scope: string, entry: RecordingArchiveEntry): void { + const previous = readRecordingArchive(scope); + const next = [entry, ...previous.filter((row) => row.id !== entry.id + && (!entry.audioPath || row.audioPath !== entry.audioPath))] + .sort((a, b) => b.recordedAt.localeCompare(a.recordedAt)); + snapshots.set(scope, next); + try { + // Playback URLs are short-lived bearer capabilities; retain paths instead. + window.localStorage.setItem(key(scope), JSON.stringify(next.filter((row) => row.audioPath).map(({ audioUrl: _url, files, ...row }) => ({ ...row, + ...(files ? { files: files.map((file) => ({ ...file, url: "" })) } : {}) + })))); + } catch { /* Keep the in-memory archive if local storage is temporarily full. */ } + for (const notify of listeners.get(scope) ?? []) notify(); +} + +export function useRecordingArchive(scope?: string): RecordingArchiveEntry[] { + return useSyncExternalStore( + (notify) => { + if (!scope) return () => undefined; + const bucket = listeners.get(scope) ?? new Set();bucket.add(notify);listeners.set(scope, bucket); + return () => { bucket.delete(notify); }; + }, + () => scope ? readRecordingArchive(scope) : empty, + () => empty + ); +} diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index 446b39933..7674f0c13 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -22,6 +22,7 @@ import { type WebuiSessionTarget } from "../api/memmy-agent-client.js"; import type { AnalyticsEvent } from "../analytics/analytics-events.js"; +import { ApiRequestError } from "../api/http.js"; import { setAnalyticsModelSource } from "../analytics/analytics-context.js"; import { buildOnboardingActivationEvent } from "../analytics/onboarding-analytics.js"; import { useAnalytics } from "../analytics/use-analytics.js"; @@ -84,9 +85,9 @@ import { type AgentQuestionCardPayload, type AgentQuestionResponse, } from "./agent-question-card.js"; -import { PluginCapabilityHost, selectRegionPluginCalls } from "./plugin-capability-host.js"; +import { PluginCapabilityHost, pinnedCapabilityKey, selectRegionPluginCalls, selectVisiblePluginCalls } from "./plugin-capability-host.js"; import { InterviewRecordingPanel, type RecordingPanelSession } from "./interview-recording-panel.js"; -import { RecordingEntryPage, useRecordingEntry, type RecordingDeliverFn } from "./recording-entry.js"; +import { RecordingEntryCard, RecordingEntryPage, useRecordingEntry, type RecordingDeliverFn } from "./recording-entry.js"; import { PluginArtifactPreviewPanel } from "./plugin-artifact-preview-panel.js"; import { AgentWorkspaceContext } from "./agent-workspace-context.js"; import { AppFrame } from "./app-frame.js"; @@ -536,9 +537,11 @@ export function collectPluginScenarios(plugins: InstalledPlugin[]): PluginScenar * Renders nothing when no plugin pinned anything, so the composer keeps its * usual layout for everyone else. */ -function PinnedPluginCommandBar(props: { +export function PinnedPluginCommandBar(props: { targets: PluginCommandTarget[]; disabled: boolean; + recordingOpen: boolean; + onRecordingPress(): void; /** Buttons whose card is on screen, as `pluginId:capabilityId`. */ active: ReadonlySet; /** Pressing a button opens its card, or closes the one it already opened. */ @@ -549,7 +552,10 @@ function PinnedPluginCommandBar(props: {
{props.targets.map((target) => { const Icon = resolveContributionIcon(target.command.icon); - const open = props.active.has(`${target.plugin.id}:${target.command.capabilityId}`); + // The composer recording entry controls its compact card independently + // of the top-bar page and pending material collection calls. + const recording = target.command.topbar === true; + const open = recording ? props.recordingOpen : props.active.has(`${target.plugin.id}:${target.command.capabilityId}`); return ( + + )} {orderedCalls.map((call) => { const plugin = plugins.get(call.pluginId); const renderer = plugin?.manifest.ui?.renderer; @@ -209,7 +257,13 @@ export function PluginCapabilityHost(props: PluginCapabilityHostProps) { ); const respond = async (interactionId: string, response: unknown) => { if (!props.client) return Promise.reject(new Error("Plugin client unavailable")); - await props.client.respond(call.pluginId, call.callId, interactionId, response); + const key = interactionKey(call, interactionId); + if (answeredInteractions.has(key)) return; + const existing = responseFlights.current.get(key); + if (existing) return existing; + const flight = props.client.respond(call.pluginId, call.callId, interactionId, response); + responseFlights.current.set(key, flight); + try { await flight; } finally { responseFlights.current.delete(key); } // A refresh is an intermediate action in a multi-step card. Keep the // renderer mounted until its next authoritative interaction arrives. if (asRecord(response).action === "refresh") return; @@ -219,16 +273,20 @@ export function PluginCapabilityHost(props: PluginCapabilityHostProps) { if (!props.client) return Promise.reject(new Error("Plugin client unavailable")); return props.client.cancel(call.pluginId, call.callId); }; - // Closing a card the user raised themselves is not an answer. The call - // is cancelled so the plugin stops waiting, and the card is dropped - // locally so a cancellation error does not take its place — the user - // asked for it to go away, not to be told it went away. + const autoPinned = pinnedCapabilities.has(pinnedCapabilityKey(call)); + // Pinned cards can reopen the same interaction. Other dismissals + // explicitly retire the call and hide its cancellation report. const dismiss = async () => { + if (autoPinned && props.onCallHidden) { + props.onCallHidden(call.callId); + return; + } setDismissedCalls((current) => new Set(current).add(`${call.pluginId}:${call.callId}`)); props.onCallDismissed?.(call.callId); await cancel().catch(() => undefined); }; - const autoPinned = pinnedCapabilities.has(`${call.pluginId}:${call.capabilityId}`); + const hidden = props.hiddenCallIds?.has(call.callId) + && !call.events.some((event) => event.type === "result" || event.type === "error"); const dismissable = (call.origin === "user" || (region === "pinned" && autoPinned)) && call.events.some((event) => event.type === "interaction") && !call.events.some((event) => event.type === "result" || event.type === "error"); @@ -238,11 +296,15 @@ export function PluginCapabilityHost(props: PluginCapabilityHostProps) { conversationId={call.conversationId} pluginId={call.pluginId} origin={call.origin} + guidance={call.pluginId === "legal-labor" && call.capabilityId === "session-start"} + diagnosticUpload={call.pluginId === "legal-labor" && ["session-start", "material-collect"].includes(call.capabilityId)} + onGenerateDiagnosis={call.origin === "user" && props.onRequestDiagnosis + ? () => setPendingDiagnoses((current) => new Set(current).add(call.callId)) : undefined} onRespond={respond} onCancel={cancel} onUploadFiles={props.uploadFiles} onDismiss={dismissable ? () => void dismiss() : undefined} - onRecordingSession={props.onRecordingSession} + onRecordingSession={props.onRecordingSession} onRecordingArchived={props.onRecordingArchived} asrClient={props.asrClient} onAddArtifact={props.onAddArtifact} onOpenArtifact={props.onOpenArtifact} @@ -255,9 +317,11 @@ export function PluginCapabilityHost(props: PluginCapabilityHostProps) { // Progress and task-list events are status updates, not user-facing // cards. Keep them in the chat flow as text so a job cannot take over // the conversation with a second progress panel. - if (isBarePresentation(call) || isStatusOnlyPresentation(call)) return
{cards}
; + if (isBarePresentation(call) || isStatusOnlyPresentation(call) + || (call.pluginId === "legal-labor" && ((call.capabilityId === "session-start" && guidanceQuestion(call.events)) + || (["session-start", "material-collect"].includes(call.capabilityId) && call.events.some((event) => event.type === "interaction" && event.request.type === "file-input"))))) return ; return ( -
+