diff --git a/.env.example b/.env.example index 4e901db56..542cac06f 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,12 @@ MEMMY_GA4_MEASUREMENT_ID= # Legal agreement page base URLs MEMMY_LEGAL_CN_BASE_URL=https://memmy.cn MEMMY_LEGAL_INTL_BASE_URL=https://memmy.bot + +# Exact hosts available to sandboxed command plugins. Keep this aligned with +# the first-party literature-review plugin's declared academic providers. +MEMMY_COMMAND_PLUGIN_NETWORK_ALLOWLIST=export.arxiv.org,arxiv.org,eutils.ncbi.nlm.nih.gov,pmc.ncbi.nlm.nih.gov,api.openalex.org,api.crossref.org + +# Trusted MPP registry. Defaults to the Cloud service, which serves +# entitlement-gated releases; set this only to point at another registry. +# HTTPS is required except for a loopback development registry. +MEMMY_PLUGIN_REGISTRY_URL= diff --git a/.github/workflows/office-plugin.yml b/.github/workflows/office-plugin.yml new file mode 100644 index 000000000..7b29de608 --- /dev/null +++ b/.github/workflows/office-plugin.yml @@ -0,0 +1,97 @@ +name: Office plugin + +on: + pull_request: + paths: + - ".github/workflows/office-plugin.yml" + - "plugins/office/**" + - "App/memmy-agent/src/skills/docx/**" + - "App/memmy-agent/src/skills/pdf/**" + - "App/memmy-agent/src/skills/pptx/**" + - "App/memmy-agent/src/skills/xlsx/**" + - "App/backend/src/adapters/outbound/plugin-runtime/**" + - "App/backend/src/adapters/inbound/local-api/routes/plugin-mcp.ts" + - "App/backend/src/tests/office-plugin-e2e.test.ts" + - "scripts/test-office-plugin-e2e.mjs" + - "App/shell/desktop/electron-builder*.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + integration: + strategy: + fail-fast: false + matrix: + os: [macos-26, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Provision release-build inputs on macOS + if: runner.os == 'macOS' + run: | + node plugins/office/scripts/provision-macos.mjs + brew install poppler + - name: Provision release-build inputs on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install libreoffice-fresh --version=26.2.6 -y --no-progress + if ($LASTEXITCODE -ne 0) { throw 'LibreOffice installation failed' } + $archive = Join-Path $env:RUNNER_TEMP 'poppler.zip' + Invoke-WebRequest 'https://github.com/oschwartz10612/poppler-windows/releases/download/v26.09.0-0/Release-26.09.0-0.zip' -OutFile $archive + if ((Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne '7a6f256a0ddf7536182246a5733331bf4677cbcc34f4663774947ad34556c8d0') { + # The exact upstream digest is pinned; never continue with an unchecked download. + throw 'Poppler archive integrity check failed' + } + $unpacked = Join-Path $env:RUNNER_TEMP 'poppler' + Expand-Archive $archive $unpacked + $root = Get-ChildItem $unpacked -Directory | Where-Object { Test-Path (Join-Path $_.FullName 'Library/bin/pdfinfo.exe') } | Select-Object -First 1 + if (!$root) { throw 'Poppler portable layout not found' } + "OFFICE_BUILD_POPPLER=$($root.FullName)" >> $env:GITHUB_ENV + - name: Install plugin build dependencies + run: npm ci --prefix plugins/office + - name: Prepare and smoke-test portable renderer + run: node plugins/office/scripts/prepare-runtime.mjs "$RUNNER_TEMP/office-runtime-tree" + - name: Install build dependencies + run: | + npm ci + npm install --prefix App/memmy-agent --ignore-scripts --no-audit --no-fund + npm run build -w @memmy/local-api-contracts + npm run build -w @memmy/knowledge + npm run build -w @memmy/agent-source-core + npm run build -w @memmy/migrations + - name: Build portable runtime archive + run: | + TARGET=$(node -p 'process.platform + "-" + process.arch') + node plugins/office/scripts/package-runtime.mjs --root "$RUNNER_TEMP/office-runtime-tree" --destination plugins/office/release --target "$TARGET" --version "ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" --tools "$RUNNER_TEMP/office-runtime-tree/runtime-tools.json" --set-default + - name: Verify runtime and permission boundaries + run: | + npm --prefix plugins/office run build + npm exec -- tsc -p App/backend/tsconfig.json --noEmit + if [ "$RUNNER_OS" = macOS ]; then npm --prefix plugins/office test; fi + - name: Cold install and run four-format end-to-end validation + env: + OFFICE_E2E_MANAGED_RUNTIME: "1" + MEMMY_PLUGIN_SANDBOX_TRACE: "1" + OFFICE_E2E_QA_DIR: ${{ runner.temp }}/office-qa + OFFICE_E2E_REPORT: ${{ runner.temp }}/office-e2e.json + run: npm run test:office-plugin:e2e + - uses: actions/upload-artifact@v4 + if: always() + with: + name: office-package-and-validation-${{ matrix.os }} + path: | + plugins/office/release/ + ${{ runner.temp }}/office-qa/ + ${{ runner.temp }}/office-e2e.json diff --git a/.github/workflows/windows-computer-history-validation.yml b/.github/workflows/windows-computer-history-validation.yml index 724d19fc8..4745a779a 100644 --- a/.github/workflows/windows-computer-history-validation.yml +++ b/.github/workflows/windows-computer-history-validation.yml @@ -5,6 +5,7 @@ on: paths: - ".github/workflows/windows-computer-history-validation.yml" - "App/native-computer-history/**" + - "App/native-computer-use/**" - "App/memmy-agent/src/tools/computer-history/**" - "App/memmy-agent/tests/tools/computer-history/**" - "App/memmy-agent/scripts/copy-build-assets.mjs" @@ -24,12 +25,15 @@ jobs: validate: runs-on: windows-2025 timeout-minutes: 30 + env: + EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} defaults: run: shell: pwsh steps: - uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - uses: actions/setup-node@v4 with: @@ -39,7 +43,9 @@ jobs: - uses: actions/setup-go@v5 with: go-version: "1.22.12" - cache-dependency-path: App/native-computer-history/apps/OpenComputerHistoryWindows/go.mod + cache-dependency-path: | + App/native-computer-history/apps/OpenComputerHistoryWindows/go.mod + App/native-computer-use/apps/OpenComputerUseWindows/go.mod - name: Require Windows 11 run: | $ErrorActionPreference = 'Stop' @@ -51,6 +57,7 @@ jobs: - name: Install standalone agent dependencies run: npm ci --prefix App/memmy-agent - name: Validate native protocol and lifecycle without desktop access + id: native_history_tests run: | $ErrorActionPreference = 'Stop' Push-Location App/native-computer-history/apps/OpenComputerHistoryWindows @@ -62,6 +69,15 @@ jobs: go vet -unsafeptr=false ./... if ($LASTEXITCODE -ne 0) { throw 'Native recorder vet failed' } } finally { Pop-Location } + - name: Validate native Computer Use Go module + id: native_computer_use_tests + run: | + $ErrorActionPreference = 'Stop' + Push-Location App/native-computer-use/apps/OpenComputerUseWindows + try { + go test ./... + if ($LASTEXITCODE -ne 0) { throw 'Native Computer Use tests failed' } + } finally { Pop-Location } - name: Build agent and native History recorder from source run: npm --prefix App/memmy-agent run build - name: Validate packaged app icons with Windows PowerShell 5.1 @@ -155,10 +171,55 @@ jobs: node node_modules/vitest/vitest.mjs run App/shell/desktop/tests/packaged-runtime-boundary.test.ts -t "requires the native Windows 11 History recorder" + - name: Emit exact-source Windows provider receipt + id: windows_provider_receipt + if: ${{ success() && steps.native_history_tests.outcome == 'success' && steps.native_computer_use_tests.outcome == 'success' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:RUNNER_OS -ne 'Windows') { throw 'Windows provider receipt requires a Windows runner' } + $head = (git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $head -ne $env:EXPECTED_SOURCE_SHA) { + throw "Executed checkout $head does not match expected source $env:EXPECTED_SOURCE_SHA" + } + git diff --quiet + if ($LASTEXITCODE -ne 0) { throw 'Tracked source changed during validation' } + git diff --cached --quiet + if ($LASTEXITCODE -ne 0) { throw 'Index changed during validation' } + $receipt = [ordered]@{ + schemaVersion = 1 + provider = 'windows' + status = 'passed' + headSha = $head + repository = $env:GITHUB_REPOSITORY + workflowRef = $env:GITHUB_WORKFLOW_REF + workflowSha = $env:GITHUB_WORKFLOW_SHA + runId = $env:GITHUB_RUN_ID + runAttempt = [int]$env:GITHUB_RUN_ATTEMPT + runnerOS = $env:RUNNER_OS + runnerArch = $env:RUNNER_ARCH + windowsBuild = [Environment]::OSVersion.Version.Build + generatedAt = [DateTime]::UtcNow.ToString('o') + nativeModules = @( + 'App/native-computer-history/apps/OpenComputerHistoryWindows' + 'App/native-computer-use/apps/OpenComputerUseWindows' + ) + liveCaptureValidated = $false + signedInstallerValidated = $false + } + $receipt | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath windows-provider-receipt.json -Encoding utf8NoBOM + - name: Upload exact-source Windows provider receipt + if: ${{ success() && steps.windows_provider_receipt.outcome == 'success' }} + uses: actions/upload-artifact@v4 + with: + name: windows-provider-receipt-${{ env.EXPECTED_SOURCE_SHA }}-${{ github.run_attempt }} + path: windows-provider-receipt.json + if-no-files-found: error + retention-days: 14 - name: Record validation scope if: ${{ always() }} run: | @' Validates source-built Windows History recorder identity, PE architecture, exact embedded worker source, pure recorder state/lifecycle self-tests, UTF-8 app catalog output, runtime asset copy, and packaging rules. This does not start live recording or prove real UI Automation/hook behavior. + Both native Windows Go modules are tested. The provider receipt is limited to the checks in this job; it does not prove live capture or signed-installer acceptance. A signed NSIS installer and a representative real-app capture matrix still require installation testing on a Windows 11 machine. '@ | Add-Content -Path $env:GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index b390d647b..421e10011 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ memmy-memory-*.tgz .DS_Store App/memmy-agent/package-lock.json sessions/ +artifacts/literature-review-*.json .* !.github/ !.github/workflows/ @@ -28,4 +29,8 @@ sessions/ !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/index.ts b/App/backend/local-api-contracts/src/index.ts index 4fd760109..e379408e8 100644 --- a/App/backend/local-api-contracts/src/index.ts +++ b/App/backend/local-api-contracts/src/index.ts @@ -1,6 +1,7 @@ /** Memmy local API contract. */ import { z } from "zod"; import { ModelThinkingConfigSchema } from "./model-thinking-levels.js"; +import { PluginCapabilityEventPayloadSchema } from "./plugin.js"; import { normalizeOutputLanguage } from "./output-language.js"; export { normalizeOutputLanguage } from "./output-language.js"; @@ -15,6 +16,7 @@ export * from "./memory-l3-world-model.js"; export * from "./endpoints.js"; export * from "./cloud-service.js"; export * from "./desktop-runtime-manifest.js"; +export * from "./plugin.js"; export * from "./computer-use-surface.js"; export * from "./browser-profile-control.js"; export * from "./embedded-browser-control.js"; @@ -1180,23 +1182,84 @@ export const ModelConfigViewSchema = z.object({ export type ModelConfigView = z.infer; /** Schema for asr transcription input. */ +/** + * Largest audio payload a single transcription may carry, in decoded bytes. + * + * The upstream file-transcription API caps one inline audio item at 20 MiB of + * base64, which leaves roughly this much actual audio. An hours-long interview + * therefore has to be recorded at a bitrate that fits, because splitting it + * restarts speaker numbering and destroys the diarization. + */ +export const ASR_MAX_AUDIO_BYTES = 15 * 1024 * 1024; + +/** Base64 inflates by 4/3; the route needs headroom for that plus the JSON envelope. */ +export const ASR_MAX_REQUEST_BYTES = Math.ceil(ASR_MAX_AUDIO_BYTES * 4 / 3) + 64 * 1024; + export const AsrTranscriptionInputSchema = z.object({ - audioBase64: z.string().min(1), + audioBase64: z.string().min(1).max(Math.ceil(ASR_MAX_AUDIO_BYTES * 4 / 3)), mimeType: z.string().min(1), - durationMs: z.number().int().nonnegative().optional() + durationMs: z.number().int().nonnegative().optional(), + /** Requests speaker separation. Honoured only by upstream models that support it. */ + diarization: z.boolean().optional() }); export type AsrTranscriptionInput = z.infer; +/** + * One diarized utterance. + * + * `speakerId` is scoped to a single transcription: the same index in two + * transcriptions does not denote the same person. + */ +export const AsrTranscriptSegmentSchema = z.object({ + text: z.string(), + speakerId: z.number().int().nonnegative().optional(), + startMs: z.number().int().nonnegative().optional(), + endMs: z.number().int().nonnegative().optional() +}); +export type AsrTranscriptSegment = z.infer; + /** Schema for asr transcription response. */ export const AsrTranscriptionResponseSchema = z.object({ text: z.string(), modelId: z.string().trim().min(1), provider: CatalogProviderIdSchema, source: z.enum(["account", "byok"]), - transcribedAt: z.string().datetime() + transcribedAt: z.string().datetime(), + /** Present only when diarization was requested and the upstream model returned it. */ + segments: z.array(AsrTranscriptSegmentSchema).optional() }); export type AsrTranscriptionResponse = z.infer; +/** + * Events on the live ASR stream (`/api/asr/stream`). + * + * The renderer pushes raw 16 kHz mono PCM as binary frames and receives these + * JSON text frames back. `partial` is a provisional reading of the sentence + * being spoken; a later frame with the same `sentenceId` replaces it, and + * `sentence` is the final text for that id. Speaker attribution is never + * present here: the realtime model does not separate speakers, so labels come + * only from the diarized pass over the finished recording. + */ +export const AsrStreamEventSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.enum(["partial", "sentence"]), + sentenceId: z.number().int().nonnegative(), + text: z.string(), + beginMs: z.number().int().nonnegative(), + endMs: z.number().int().nonnegative().optional() + }), + z.object({ type: z.literal("finished") }), + z.object({ type: z.literal("error"), message: z.string() }) +]); +export type AsrStreamEvent = z.infer; + +/** Control frames the renderer sends on the live ASR stream. */ +export const AsrStreamControlSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("start"), sampleRate: z.number().int().positive().optional() }), + z.object({ type: z.literal("finish") }) +]); +export type AsrStreamControl = z.infer; + export const AccountChannelSchema = z.enum(["email", "phone"]); export type AccountChannel = z.infer; @@ -1674,11 +1737,29 @@ export const ScanCompletedSseEventSchema = z.object({ }) }); +export const PluginCapabilitySseEventSchema = z.object({ + id: z.string(), + type: z.literal("plugin.capability_event"), + timestamp: z.string().datetime(), + payload: PluginCapabilityEventPayloadSchema +}); + +export const PluginCapabilitySnapshotSseEventSchema = z.object({ + id: z.string(), + type: z.literal("plugin.capability_snapshot"), + timestamp: z.string().datetime(), + payload: z.object({ + calls: z.array(PluginCapabilityEventPayloadSchema.pick({ pluginId: true, capabilityId: true, callId: true, conversationId: true })) + }) +}); + export const SseEventSchema = z.discriminatedUnion("type", [ ConnectedSseEventSchema, HeartbeatSseEventSchema, ScanProgressSseEventSchema, - ScanCompletedSseEventSchema + ScanCompletedSseEventSchema, + PluginCapabilitySseEventSchema, + PluginCapabilitySnapshotSseEventSchema ]); export type SseEvent = z.infer; diff --git a/App/backend/local-api-contracts/src/memory-runtime.ts b/App/backend/local-api-contracts/src/memory-runtime.ts index 024a5c480..3c909be58 100644 --- a/App/backend/local-api-contracts/src/memory-runtime.ts +++ b/App/backend/local-api-contracts/src/memory-runtime.ts @@ -521,6 +521,33 @@ export const SearchInputSchema = RuntimeRequestFieldsSchema.extend({ }); export type SearchInput = z.infer; +/** Generate vectors with the embedding model currently owned by the Memory runtime. */ +export const EmbeddingInferenceInputSchema = z.object({ + texts: z.array(z.string().min(1).max(16_000)).min(1).max(256), + role: z.enum(["query", "document"]).default("document") +}).superRefine((input, context) => { + const totalCharacters = input.texts.reduce((sum, text) => sum + text.length, 0); + if (totalCharacters > 200_000) { + context.addIssue({ + code: "custom", + path: ["texts"], + message: "Total embedding input exceeds 200000 characters" + }); + } +}); +export type EmbeddingInferenceInput = z.input; + +export const EmbeddingInferenceOutputSchema = z.object({ + embeddings: z.array(z.array(z.number())), + model: z.object({ + provider: z.string().min(1), + model: z.string().min(1), + mode: z.enum(["cloud", "local", "custom"]), + dimension: z.number().int().positive() + }) +}); +export type EmbeddingInferenceOutput = z.infer; + /** Schema for default search output. */ export const DefaultSearchOutputSchema = z.object({ injectedContext: z.string() @@ -976,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/local-api-contracts/src/model-catalog-resolver.ts b/App/backend/local-api-contracts/src/model-catalog-resolver.ts index af108ff8c..a5a7b0a9c 100644 --- a/App/backend/local-api-contracts/src/model-catalog-resolver.ts +++ b/App/backend/local-api-contracts/src/model-catalog-resolver.ts @@ -359,13 +359,13 @@ function protocolSupportsCapability( } function validStringRecord(value: unknown): boolean { - return value === undefined || ( + return value === undefined || value === null || ( isRecord(value) && Object.values(value).every((entry) => typeof entry === "string") ); } function validUnknownRecord(value: unknown): boolean { - return value === undefined || isRecord(value); + return value === undefined || value === null || isRecord(value); } function isHttpUrl(value: unknown): value is string { diff --git a/App/backend/local-api-contracts/src/plugin.ts b/App/backend/local-api-contracts/src/plugin.ts new file mode 100644 index 000000000..0392765c1 --- /dev/null +++ b/App/backend/local-api-contracts/src/plugin.ts @@ -0,0 +1,475 @@ +/** Memmy Plugin Protocol contracts. */ +import { z } from "zod"; + +export const JsonSchemaSchema = z.record(z.string(), z.unknown()); +export type JsonSchema = z.infer; + +const PluginIdentifierSchema = z.string().trim().min(1).max(128).regex(/^[a-z0-9][a-z0-9._-]*$/); +const PluginPackagePathSchema = z.string().trim().min(1).max(512).refine((value) => ( + !value.startsWith("/") + && !value.includes("\\") + && !value.includes("\0") + && !/^[A-Za-z]:/.test(value) + && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..") +), "Plugin package path must be a safe relative path"); + +const PluginNetworkHostSchema = z.string().trim().min(1).max(253).transform((value) => value.toLowerCase()).refine((value) => { + if (value === "localhost") return true; + if (value.includes(":") || value.includes("/") || value.includes("*") || value.startsWith(".")) return false; + return value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)); +}, "Network host must be an exact DNS hostname without a scheme, port, path, or wildcard"); + +/** Account grant identifier, namespaced by the issuer, e.g. `plugin:`. */ +const PluginEntitlementSchema = z.string().trim().min(1).max(128).regex(/^[a-z0-9][a-z0-9:._-]*$/); + +export const PluginRuntimeSchema = z.object({ + adapter: z.enum(["mcp", "http", "command"]), + config: z.record(z.string(), z.unknown()).optional() +}); +export type PluginRuntime = z.infer; + +export const PluginModelPolicySchema = z.object({ + /** Restricts host-service inference to presets served by the signed-in account. */ + requiredSource: z.literal("account") +}); +export type PluginModelPolicy = z.infer; + +export const PluginCapabilityControlSchema = z.object({ + action: z.literal("cancel"), + runIdInput: z.string().trim().min(1).max(128).regex(/^[A-Za-z_][A-Za-z0-9_.-]*$/).default("runId"), + scopeInput: z.string().trim().min(1).max(128).regex(/^[A-Za-z_][A-Za-z0-9_.-]*$/).default("scope"), + taskIdInput: z.string().trim().min(1).max(128).regex(/^[A-Za-z_][A-Za-z0-9_.-]*$/).default("taskId") +}); +export type PluginCapabilityControl = z.infer; + +export const PluginCapabilitySchema = z.object({ + id: PluginIdentifierSchema, + name: z.string().trim().min(1).max(128), + description: z.string().trim().min(1).max(2_000), + inputSchema: JsonSchemaSchema, + outputSchema: JsonSchemaSchema, + execution: z.enum(["request", "job"]), + /** Optional Host-owned execution control performed before invoking this capability. */ + control: PluginCapabilityControlSchema.optional(), + examples: z.array(z.string().trim().min(1).max(500)).max(20).optional() +}); +export type PluginCapability = z.infer; + +export const PluginPermissionSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("network"), + hosts: z.array(PluginNetworkHostSchema).min(1), + description: z.string().trim().min(1).optional() + }), + z.object({ + type: z.literal("filesystem"), + paths: z.array(z.string().trim().min(1)).min(1), + access: z.enum(["read", "write", "read-write"]), + description: z.string().trim().min(1).optional() + }), + z.object({ + type: z.literal("secret"), + keys: z.array(PluginIdentifierSchema).min(1), + description: z.string().trim().min(1).optional() + }), + z.object({ + type: z.literal("host-service"), + services: z.array(PluginIdentifierSchema).min(1), + description: z.string().trim().min(1).optional() + }) +]); +export type PluginPermission = z.infer; + +export const PluginUiRendererSchema = z.object({ + entry: PluginPackagePathSchema, + capabilities: z.array(PluginIdentifierSchema).min(1).max(100).optional(), + height: z.number().int().min(120).max(1_200).optional() +}); +export type PluginUiRenderer = z.infer; + +export const PluginUiSchema = z.object({ + renderer: PluginUiRendererSchema.optional(), + surface: PluginUiRendererSchema.optional() +}).passthrough(); +export type PluginUi = z.infer; + +export const PluginSkillContributionSchema = z.object({ + id: PluginIdentifierSchema, + name: z.string().trim().min(1).max(128), + description: z.string().trim().min(1).max(500), + entry: PluginPackagePathSchema, + /** Private instructions are resolved in memory by a cloud-skill-aware Host. */ + cloud: z.object({ + revision: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/), + sha256: z.string().regex(/^[a-f0-9]{64}$/) + }).strict().optional() +}).superRefine((skill, context) => { + if (!skill.entry.endsWith("/SKILL.md") && skill.entry !== "SKILL.md") { + context.addIssue({ code: "custom", path: ["entry"], message: "Plugin skill entry must point to SKILL.md" }); + } +}); +export type PluginSkillContribution = z.infer; + +export const PluginCommandContributionSchema = z.object({ + command: z.string().trim().regex(/^\/[a-z0-9][a-z0-9-]{0,63}$/), + name: z.string().trim().min(1).max(128), + description: z.string().trim().min(1).max(500), + capabilityId: PluginIdentifierSchema, + agentSkillId: PluginIdentifierSchema.optional(), + argHint: z.string().trim().max(128).optional(), + icon: z.string().trim().min(1).max(64).optional(), + surface: z.boolean().optional(), + /** + * Also offer this command as a fixed button above the composer. + * + * For capabilities the user reaches repeatedly and out of order, where + * finding a slash command each time is the wrong interaction. + */ + pinned: z.boolean().optional(), + /** + * Also offer this command as an icon button in the conversation top bar. + * + * For a capability whose panel is a place the user works alongside the + * conversation rather than a step in it — recording an interview is the + * case this exists for. The button toggles the panel; it does not start any + * work on its own. + */ + topbar: z.boolean().optional() +}); +export type PluginCommandContribution = z.infer; + +/** + * An entry card offered on the empty home screen. + * + * A scenario is the way in for someone who does not yet know what to type. The + * card fills the composer with its prompt rather than invoking anything, so the + * user reads and can edit the request before it is sent, and the Agent picks up + * the work through its normal Skill routing. + */ +export const PluginScenarioContributionSchema = z.object({ + id: PluginIdentifierSchema, + name: z.string().trim().min(1).max(64), + description: z.string().trim().min(1).max(200), + icon: z.string().trim().min(1).max(64).optional(), + /** Text placed in the composer when the card is clicked. */ + prompt: z.string().trim().min(1).max(2_000) +}); +export type PluginScenarioContribution = z.infer; + +/** Product policy declared by the plugin; account facts are supplied by the Host. */ +export const PluginRulesSchema = z.object({ + distribution: z.enum(["public", "account"]).default("public"), + installation: z.enum(["manual", "automatic"]).default("manual"), + requiresLogin: z.boolean().default(false), + unavailableVisibility: z.enum(["visible", "hidden"]).default("visible"), + autoUpdate: z.boolean().default(false), + recommendsOfficialModel: z.boolean().default(false) +}); +export type PluginRules = z.infer; +export const PluginEligibilitySchema = z.object({ + visible: z.boolean(), + canInstall: z.boolean(), + canUse: z.boolean(), + reason: z.enum(["login_required", "entitlement_required"]).nullable() +}); +export type PluginEligibility = z.infer; + +export const PluginManifestSchema = z.object({ + apiVersion: z.literal("memmy/v1"), + id: PluginIdentifierSchema, + name: z.string().trim().min(1).max(128), + version: z.string().trim().min(1).max(64), + runtime: PluginRuntimeSchema, + capabilities: z.array(PluginCapabilitySchema).min(1), + permissions: z.array(PluginPermissionSchema), + configSchema: JsonSchemaSchema.optional(), + skills: z.array(PluginSkillContributionSchema).max(20).optional(), + commands: z.array(PluginCommandContributionSchema).max(100).optional(), + scenarios: z.array(PluginScenarioContributionSchema).max(8).optional(), + ui: PluginUiSchema.optional(), + rules: PluginRulesSchema.optional(), + /** Account grant the Host verifies before installing, enabling, or invoking this plugin. */ + requiredEntitlement: PluginEntitlementSchema.optional(), + /** Host-enforced restriction on the model presets this plugin may reach through host services. */ + modelPolicy: PluginModelPolicySchema.optional() +}).superRefine((manifest, context) => { + const ids = new Set(); + for (const [index, capability] of manifest.capabilities.entries()) { + if (ids.has(capability.id)) { + context.addIssue({ + code: "custom", + path: ["capabilities", index, "id"], + message: `Duplicate capability id: ${capability.id}` + }); + } + ids.add(capability.id); + if (capability.control) { + const properties = capability.inputSchema.properties; + const declared = properties && typeof properties === "object" && !Array.isArray(properties) + ? properties as Record + : {}; + for (const field of [capability.control.runIdInput, capability.control.scopeInput, capability.control.taskIdInput]) { + if (!Object.hasOwn(declared, field)) { + context.addIssue({ + code: "custom", + path: ["capabilities", index, "control"], + message: `Cancellation control input field is not declared by the capability schema: ${field}` + }); + } + } + } + } + for (const slot of ["renderer", "surface"] as const) { + for (const [index, capabilityId] of (manifest.ui?.[slot]?.capabilities ?? []).entries()) { + if (!ids.has(capabilityId)) { + context.addIssue({ + code: "custom", + path: ["ui", slot, "capabilities", index], + message: `Unknown ${slot} capability id: ${capabilityId}` + }); + } + } + } + const skills = new Set(); + for (const [index, skill] of (manifest.skills ?? []).entries()) { + if (skills.has(skill.id)) { + context.addIssue({ code: "custom", path: ["skills", index, "id"], message: `Duplicate plugin skill id: ${skill.id}` }); + } + skills.add(skill.id); + } + const commands = new Set(); + for (const [index, command] of (manifest.commands ?? []).entries()) { + if (!ids.has(command.capabilityId)) { + context.addIssue({ code: "custom", path: ["commands", index, "capabilityId"], message: `Unknown command capability id: ${command.capabilityId}` }); + } + if (commands.has(command.command)) { + context.addIssue({ code: "custom", path: ["commands", index, "command"], message: `Duplicate plugin command: ${command.command}` }); + } + if (command.agentSkillId && !skills.has(command.agentSkillId)) { + context.addIssue({ code: "custom", path: ["commands", index, "agentSkillId"], message: `Unknown command Agent skill id: ${command.agentSkillId}` }); + } + if (command.agentSkillId && command.surface) { + context.addIssue({ code: "custom", path: ["commands", index, "surface"], message: "Agent-routed plugin commands cannot open a direct plugin surface" }); + } + commands.add(command.command); + } +}); +export type PluginManifest = z.infer; + +export const CapabilityCallSchema = z.object({ + callId: z.string().trim().min(1), + pluginId: PluginIdentifierSchema, + capabilityId: PluginIdentifierSchema, + conversationId: z.string().trim().min(1), + /** Exact Agent preset selected for the turn that initiated this call. */ + modelPreset: z.string().trim().min(1).optional(), + input: z.unknown(), + deadline: z.string().datetime().optional() +}); +export type CapabilityCall = z.infer; + +export const PluginInteractionRequestSchema = z.object({ + interactionId: z.string().trim().min(1), + type: z.enum(["question", "approval", "file-input", "audio-record", "custom"]), + payload: z.unknown(), + responseSchema: JsonSchemaSchema.optional() +}); +export type PluginInteractionRequest = z.infer; + +export const PluginTaskSchema = z.object({ + id: z.string().trim().min(1), + title: z.string().trim().min(1), + status: z.enum(["pending", "running", "completed", "failed"]) +}); +export type PluginTask = z.infer; + +/** + * What a plugin's file is for, so the Host can label and place it correctly. + * + * `setup` is something handed over before the work happens — an intake + * checklist, a survey template, a workbook the user fills in on site. Calling + * those "deliverables" reads as the engagement being finished when nothing has + * been produced yet. Anything a plugin does not classify keeps the old meaning, + * which is what every artifact was before this field existed. + */ +export const PluginArtifactRoleSchema = z.enum(["setup", "deliverable"]); +export type PluginArtifactRole = z.infer; + +export const PluginArtifactRefSchema = z.object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), + 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; + +/** Private command-adapter message requesting a Host-owned service. Never forwarded to plugin API consumers. */ +export const PluginHostServiceRequestSchema = z.object({ + type: z.literal("host-service-request"), + requestId: z.string().trim().min(1), + service: PluginIdentifierSchema, + input: z.unknown() +}); +export type PluginHostServiceRequest = z.infer; + +export const PluginHostServiceResponseSchema = z.object({ + type: z.literal("host-service-response"), + callId: z.string().trim().min(1), + requestId: z.string().trim().min(1), + response: z.unknown().optional(), + error: z.object({ + code: z.string().trim().min(1), + message: z.string().trim().min(1), + retryable: z.boolean() + }).optional() +}).refine((value) => value.response !== undefined || value.error !== undefined, { + message: "Host service response requires response or error" +}); +export type PluginHostServiceResponse = z.infer; + +export const CapabilityEventSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("progress"), + current: z.number().nonnegative(), + total: z.number().positive().optional(), + message: z.string().optional(), + cancellable: z.boolean().optional() + }), + z.object({ type: z.literal("task-list"), tasks: z.array(PluginTaskSchema) }), + z.object({ type: z.literal("interaction"), request: PluginInteractionRequestSchema }), + z.object({ type: z.literal("artifact"), artifact: PluginArtifactRefSchema }), + z.object({ type: z.literal("result"), output: z.unknown() }), + z.object({ + type: z.literal("error"), + code: z.string().trim().min(1), + message: z.string().trim().min(1), + retryable: z.boolean() + }) +]); +export type CapabilityEvent = z.infer; + +export const PluginStateSchema = z.enum([ + "installed", + "pending_approval", + "enabling", + "active", + "disabling", + "disabled", + "failed" +]); +export type PluginState = z.infer; + +export const InstalledPluginSchema = z.object({ + activeCallCount: z.number().int().nonnegative().optional(), + update: z.object({ + status: z.enum(["waiting", "installing"]), + version: z.string().optional() + }).optional(), + id: PluginIdentifierSchema, + version: z.string().min(1), + manifest: PluginManifestSchema, + eligibility: PluginEligibilitySchema.optional(), + state: PluginStateSchema, + approvedPermissions: z.array(PluginPermissionSchema), + config: z.record(z.string(), z.unknown()), + lastError: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime() +}); +export type InstalledPlugin = z.infer; +export const InstalledPluginsSchema = z.array(InstalledPluginSchema); + +export const InstallPluginInputSchema = z.object({ + pluginId: PluginIdentifierSchema, + version: z.string().trim().min(1).max(64).optional() +}); +export type InstallPluginInput = z.infer; + +export const UpdatePluginConfigInputSchema = z.object({ + config: z.record(z.string(), z.unknown()), + secrets: z.record(PluginIdentifierSchema, z.string()).optional() +}); +export type UpdatePluginConfigInput = z.infer; + +export const UpdatePluginPermissionsInputSchema = z.object({ + permissions: z.array(PluginPermissionSchema) +}); +export type UpdatePluginPermissionsInput = z.infer; + +export const UpdatePluginInputSchema = z.object({ + version: z.string().trim().min(1).max(64).optional() +}); +export type UpdatePluginInput = z.infer; + +/** + * Who asked for a capability call. + * + * The Host reads this to decide how a card behaves when the user closes it. + * An agent-invoked card is a step in the model's turn, so closing it has to + * answer — otherwise the turn stalls. A user-invoked card is not part of any + * turn, so closing it just closes it. Defaults to `agent` because tool calls + * are the common case and predate this field. + */ +export const PluginInvocationOriginSchema = z.enum(["user", "agent"]); +export type PluginInvocationOrigin = z.infer; + +export const InvokePluginCapabilityInputSchema = z.object({ + conversationId: z.string().trim().min(1), + input: z.unknown(), + deadline: z.string().datetime().optional(), + origin: PluginInvocationOriginSchema.default("agent") +}); +export type InvokePluginCapabilityInput = z.infer; + +export const InvokePluginCapabilityResponseSchema = z.object({ + callId: z.string().trim().min(1), + event: CapabilityEventSchema +}); +export type InvokePluginCapabilityResponse = z.infer; + +export const PluginInteractionResponseInputSchema = z.object({ + response: z.unknown() +}); +export type PluginInteractionResponseInput = z.infer; + +export const PluginUiRendererResponseSchema = z.object({ html: z.string() }); +export type PluginUiRendererResponse = z.infer; +export const PluginUiSlotSchema = z.enum(["renderer", "surface"]); +export type PluginUiSlot = z.infer; + +export const PluginCapabilityEventPayloadSchema = z.object({ + pluginId: PluginIdentifierSchema, + capabilityId: PluginIdentifierSchema, + callId: z.string().min(1), + conversationId: z.string().min(1), + origin: PluginInvocationOriginSchema.default("agent"), + agentToolCallId: z.string().min(1).optional(), + turnId: z.string().min(1).optional(), + event: CapabilityEventSchema +}); +export type PluginCapabilityEventPayload = z.infer; + +export const PluginCatalogEntrySchema = z.object({ + manifest: PluginManifestSchema, + eligibility: PluginEligibilitySchema, + installed: InstalledPluginSchema.optional() +}); +export const PluginCatalogSchema = z.array(PluginCatalogEntrySchema); +export type PluginCatalogEntry = z.infer; + +/** Persistable identity only. Private instruction text must never enter a transcript. */ +export const PluginSkillRefSchema = z.object({ + pluginId: PluginIdentifierSchema, + skillId: PluginIdentifierSchema, + pluginVersion: z.string().min(1).max(128), + revision: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/), + sha256: z.string().regex(/^[a-f0-9]{64}$/) +}).strict(); +export type PluginSkillRef = z.infer; +export const PluginSkillActivationSchema = z.object({ + kind: z.literal("memmy_plugin_skill_ref_v1"), ref: PluginSkillRefSchema +}).strict(); diff --git a/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts b/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts index 2a4dc0c13..389743653 100644 --- a/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts +++ b/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts @@ -98,6 +98,24 @@ describe("resolveAssignedModel", () => { } }); + it("treats persisted null provider options as unconfigured optional maps", () => { + const current = catalog() as any; + current.providers.memmy_account.extraHeaders = null; + current.providers.memmy_account.extraBody = null; + + const resolved = resolveAssignedModel({ + catalog: current, + mode: "account", + activeAccountId: "account-a", + capability: "memory_summary" + }); + + expect(resolved).toEqual(expect.objectContaining({ + ok: true, + provider: expect.objectContaining({ extraHeaders: {}, extraBody: {} }) + })); + }); + it("does not fall back when an explicit preset is unassigned", () => { expect(resolveAssignedModel({ catalog: catalog(), diff --git a/App/backend/local-api-contracts/tests/plugin.test.ts b/App/backend/local-api-contracts/tests/plugin.test.ts new file mode 100644 index 000000000..7097705de --- /dev/null +++ b/App/backend/local-api-contracts/tests/plugin.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { + CapabilityEventSchema, + PluginCapabilityEventPayloadSchema, + PluginManifestSchema, + type PluginManifest +} from "../src/plugin.js"; + +const manifest: PluginManifest = { + apiVersion: "memmy/v1", + id: "com.example.literature-review", + name: "Literature Review", + version: "1.0.0", + runtime: { adapter: "http", config: { baseUrl: "https://plugin.example" } }, + capabilities: [ + { + id: "review", + name: "Review literature", + description: "Search and summarize literature", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "job" + } + ], + permissions: [{ type: "network", hosts: ["plugin.example"] }] +}; + +describe("PluginManifestSchema", () => { + it("accepts a language-independent plugin manifest", () => { + expect(PluginManifestSchema.parse(manifest)).toEqual(manifest); + }); + + it("accepts a Host-owned cancellation control declaration", () => { + const parsed = PluginManifestSchema.parse({ + ...manifest, + capabilities: [{ + ...manifest.capabilities[0], + id: "cancel", + inputSchema: { + type: "object", + properties: { runId: { type: "string" }, scope: { type: "string" }, taskId: { type: "string" } } + }, + control: { action: "cancel", runIdInput: "runId", scopeInput: "scope", taskIdInput: "taskId" } + }] + }); + expect(parsed.capabilities[0]?.control).toEqual({ + action: "cancel", + runIdInput: "runId", + scopeInput: "scope", + taskIdInput: "taskId" + }); + }); + + it("rejects duplicate capability ids", () => { + expect(() => PluginManifestSchema.parse({ + ...manifest, + capabilities: [manifest.capabilities[0], manifest.capabilities[0]] + })).toThrow(/Duplicate capability id/); + }); + + it("requires explicit permissions even when empty", () => { + const { permissions: _permissions, ...withoutPermissions } = manifest; + expect(() => PluginManifestSchema.parse(withoutPermissions)).toThrow(); + }); + + it("accepts a scoped renderer and rejects unsafe or unknown entries", () => { + expect(PluginManifestSchema.parse({ + ...manifest, + ui: { renderer: { entry: "ui/index.html", capabilities: ["review"], height: 320 } } + }).ui?.renderer).toEqual({ entry: "ui/index.html", capabilities: ["review"], height: 320 }); + + expect(() => PluginManifestSchema.parse({ + ...manifest, + ui: { renderer: { entry: "../outside.html" } } + })).toThrow(/safe relative path/); + expect(() => PluginManifestSchema.parse({ + ...manifest, + ui: { renderer: { entry: "ui/index.html", capabilities: ["missing"] } } + })).toThrow(/Unknown renderer capability/); + }); + + it("registers commands and a full plugin surface against declared capabilities", () => { + const parsed = PluginManifestSchema.parse({ + ...manifest, + commands: [{ command: "/review", name: "Review", description: "Create a review", capabilityId: "review", surface: true }], + ui: { surface: { entry: "ui/surface.html", capabilities: ["review"] } } + }); + expect(parsed.commands?.[0]?.command).toBe("/review"); + expect(parsed.ui?.surface?.entry).toBe("ui/surface.html"); + expect(() => PluginManifestSchema.parse({ + ...manifest, + commands: [{ command: "/review", name: "Review", description: "Create a review", capabilityId: "missing" }] + })).toThrow(/Unknown command capability/); + }); + + it("registers Agent-routed commands only against packaged skills", () => { + const parsed = PluginManifestSchema.parse({ + ...manifest, + skills: [{ id: "literature-review", name: "Literature Review", description: "Coordinate review tools", entry: "skills/literature-review/SKILL.md" }], + commands: [{ + command: "/literature-review", + name: "Literature Review", + description: "Create a review", + capabilityId: "review", + agentSkillId: "literature-review" + }] + }); + expect(parsed.commands?.[0]?.agentSkillId).toBe("literature-review"); + expect(() => PluginManifestSchema.parse({ + ...manifest, + commands: [{ command: "/review", name: "Review", description: "Create a review", capabilityId: "review", agentSkillId: "missing" }] + })).toThrow(/Unknown command Agent skill id/); + expect(() => PluginManifestSchema.parse({ + ...manifest, + skills: [{ id: "review", name: "Review", description: "Coordinate review tools", entry: "skills/review/SKILL.md" }], + commands: [{ command: "/review", name: "Review", description: "Create a review", capabilityId: "review", agentSkillId: "review", surface: true }] + })).toThrow(/cannot open a direct plugin surface/); + }); + + it("registers packaged skills and accepts only exact network hostnames", () => { + const parsed = PluginManifestSchema.parse({ + ...manifest, + permissions: [{ type: "network", hosts: ["API.CROSSREF.ORG"] }], + skills: [{ id: "literature-review", name: "Literature Review", description: "Coordinate review tools", entry: "skills/literature-review/SKILL.md" }] + }); + expect(parsed.permissions).toEqual([{ type: "network", hosts: ["api.crossref.org"] }]); + expect(parsed.skills?.[0]?.entry).toBe("skills/literature-review/SKILL.md"); + for (const host of ["https://api.crossref.org", "*.crossref.org", "api.crossref.org:443"]) { + expect(() => PluginManifestSchema.parse({ ...manifest, permissions: [{ type: "network", hosts: [host] }] })).toThrow(/exact DNS hostname/); + } + expect(() => PluginManifestSchema.parse({ + ...manifest, + skills: [{ id: "review", name: "Review", description: "Review", entry: "skills/review.md" }] + })).toThrow(/SKILL.md/); + }); +}); + +describe("CapabilityEventSchema", () => { + it("keeps progress non-blocking and interaction explicit", () => { + expect(CapabilityEventSchema.parse({ type: "progress", current: 1, total: 3 })).toEqual({ + type: "progress", + current: 1, + total: 3 + }); + expect(CapabilityEventSchema.parse({ + type: "interaction", + request: { interactionId: "question-1", type: "question", payload: { title: "Scope" } } + }).type).toBe("interaction"); + }); + + it("wraps generic plugin events with call routing context", () => { + 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: "/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"); + } + }); +}); diff --git a/App/backend/package.json b/App/backend/package.json index 562e02d1c..b6e85997d 100644 --- a/App/backend/package.json +++ b/App/backend/package.json @@ -14,7 +14,7 @@ "scripts": { "workspace-bridge:build": "node src/adapters/outbound/skill-writer/workspace-bridge/build-runtime.mjs", "workspace-bridge:build:dist": "node src/adapters/outbound/skill-writer/workspace-bridge/build-runtime.mjs --dist", - "build": "npm run build -w @memmy/knowledge && npm run build -w @memmy/agent-source-core && npm run build -w @memmy/migrations && npm run build -w @memmy/local-api-contracts && node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node -e \"const fs=require('node:fs'); fs.cpSync('src/infrastructure/app-state-store/migrations', 'dist/src/infrastructure/app-state-store/migrations', { recursive: true }); fs.cpSync('src/infrastructure/agent-source-store/migrations', 'dist/src/infrastructure/agent-source-store/migrations', { recursive: true }); fs.cpSync('src/infrastructure/idempotency-store/migrations', 'dist/src/infrastructure/idempotency-store/migrations', { recursive: true }); if (fs.existsSync('src/adapters/outbound/agent-adapter/plugins')) fs.cpSync('src/adapters/outbound/agent-adapter/plugins', 'dist/src/adapters/outbound/agent-adapter/plugins', { recursive: true });\" && npm run workspace-bridge:build:dist", + "build": "npm run build -w @memmy/knowledge && npm run build -w @memmy/agent-source-core && npm run build -w @memmy/migrations && npm run build -w @memmy/local-api-contracts && node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node -e \"const fs=require('node:fs'); fs.cpSync('src/infrastructure/app-state-store/migrations', 'dist/src/infrastructure/app-state-store/migrations', { recursive: true }); fs.cpSync('src/infrastructure/agent-source-store/migrations', 'dist/src/infrastructure/agent-source-store/migrations', { recursive: true }); fs.cpSync('src/infrastructure/idempotency-store/migrations', 'dist/src/infrastructure/idempotency-store/migrations', { recursive: true }); fs.cpSync('src/adapters/outbound/plugin-runtime/windows', 'dist/src/adapters/outbound/plugin-runtime/windows', { recursive: true }); if (fs.existsSync('src/adapters/outbound/agent-adapter/plugins')) fs.cpSync('src/adapters/outbound/agent-adapter/plugins', 'dist/src/adapters/outbound/agent-adapter/plugins', { recursive: true });\" && npm run workspace-bridge:build:dist", "lint": "eslint \"src/**/*.ts\" \"vitest.config.ts\"", "typecheck": "npm run build -w @memmy/knowledge && npm run build -w @memmy/agent-source-core && npm run build -w @memmy/migrations && npm run build -w @memmy/local-api-contracts && tsc -p tsconfig.json --noEmit", "test": "npm run build -w @memmy/knowledge && npm run build -w @memmy/agent-source-core && npm run build -w @memmy/migrations && npm run build -w @memmy/local-api-contracts && npm run workspace-bridge:build && vitest run", @@ -23,18 +23,24 @@ }, "dependencies": { "@memmy/agent-source-core": "0.0.0", + "@memmy/knowledge": "0.0.0", "@memmy/local-api-contracts": "0.0.0", "@memmy/migrations": "0.0.0", "@modelcontextprotocol/sdk": "^1.29.0", + "ajv": "^8.20.0", "dotenv": "^16.6.1", "fastify": "^5.8.5", "fzstd": "^0.1.1", + "jszip": "^3.10.1", "sqlite-vec": "0.1.9", + "ws": "^8.21.0", "yaml": "^2.9.0", - "zod": "^4.4.3", - "@memmy/knowledge": "0.0.0" + "yauzl": "3.2.0", + "zod": "^4.4.3" }, "devDependencies": { + "@types/ws": "^8.18.1", + "@types/yauzl": "2.10.3", "esbuild": "^0.27.4" } } diff --git a/App/backend/src/adapters/inbound/local-api/routes/asr.ts b/App/backend/src/adapters/inbound/local-api/routes/asr.ts index a7a2735df..6a4dc417f 100644 --- a/App/backend/src/adapters/inbound/local-api/routes/asr.ts +++ b/App/backend/src/adapters/inbound/local-api/routes/asr.ts @@ -1,5 +1,6 @@ /** Asr module. */ import { + ASR_MAX_REQUEST_BYTES, AsrTranscriptionInputSchema, AsrTranscriptionResponseSchema } from "@memmy/local-api-contracts"; @@ -17,7 +18,13 @@ export interface RegisterAsrRoutesOptions { export function registerAsrRoutes(app: FastifyInstance, options: RegisterAsrRoutesOptions): void { app.post( "/api/asr/transcriptions", - { preHandler: options.authenticateRuntimeToken }, + { + preHandler: options.authenticateRuntimeToken, + // Fastify defaults to 1 MiB, which only fits a couple of minutes of + // speech. Raised here rather than server-wide so every other route keeps + // the tight default. + bodyLimit: ASR_MAX_REQUEST_BYTES + }, withErrorEnvelope(async (request, reply) => { const input = AsrTranscriptionInputSchema.parse(request.body); const response = AsrTranscriptionResponseSchema.parse(await options.asr.transcribe(input)); diff --git a/App/backend/src/adapters/inbound/local-api/routes/plugin-mcp.ts b/App/backend/src/adapters/inbound/local-api/routes/plugin-mcp.ts new file mode 100644 index 000000000..8740c5c31 --- /dev/null +++ b/App/backend/src/adapters/inbound/local-api/routes/plugin-mcp.ts @@ -0,0 +1,341 @@ +import { SkillRefSchema, type PluginSkillContentService } from "../../../../services/plugin-skill-content-service.js"; +/** MCP bridge exposing active plugin capabilities to Memmy Agent. */ +import { createHash, randomUUID } from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { CallToolRequestSchema, ListToolsRequestSchema, isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { CapabilityCall, CapabilityEvent, InstalledPlugin, JsonSchema, PluginArtifactRef } from "@memmy/local-api-contracts"; +import type { PluginService } from "../../../../services/plugin-service.js"; +import type { ProgressBus } from "../../../../services/progress-bus.js"; + +const MCP_TOKEN_HEADER = "x-memmy-mcp-token"; +const MCP_ROUTE_PATH = "/mcp/plugins"; +const PLUGIN_MCP_KEEPALIVE_MS = 30_000; + +export type PluginMcpService = Pick & Partial>; + +export interface RegisterPluginMcpRoutesOptions { + plugins: PluginMcpService; + progressBus: ProgressBus; + mcpToken: string; + skillContent?: PluginSkillContentService; +} + +interface CapabilityTool { + name: string; + plugin: InstalledPlugin; + capability: InstalledPlugin["manifest"]["capabilities"][number]; + wrapsInput: boolean; +} + +export function buildPluginMcpServer( + plugins: PluginMcpService, + progressBus: ProgressBus, + keepaliveIntervalMs = PLUGIN_MCP_KEEPALIVE_MS, + skillContent?: PluginSkillContentService +): Server { + const server = new Server({ name: "memmy-plugins", version: "1.0.0" }, { capabilities: { tools: {} } }); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = capabilityTools(plugins); + const hasActiveCloudSkill = tools.some(({ plugin }) => plugin.manifest.skills?.some((skill) => skill.cloud)); + return { tools: [...(skillContent && hasActiveCloudSkill ? [{ + name: "read_plugin_skill", + description: "Activate private plugin instructions for this conversation. Returns only a version reference; the runtime loads instructions in memory before the next model request. Requires a cloud-skill-aware Memmy client.", + inputSchema: { type: "object", properties: { pluginId: { type: "string" }, skillId: { type: "string" } }, required: ["pluginId", "skillId"], additionalProperties: false } + }] : []), ...tools.map(({ name, plugin, capability, wrapsInput }) => ({ + name, + description: `[${plugin.manifest.name}] ${capability.description}${ + capability.examples?.length ? `\nExamples: ${capability.examples.join("; ")}` : "" + }`, + inputSchema: mcpInputSchema(capability.inputSchema, wrapsInput) + }))] }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + if (request.params.name === "read_plugin_skill" || request.params.name === "resolve_plugin_skill_context") { + if (!skillContent || extra._meta?.["memmy.dev/cloud-skills"] !== "1") return toolError("plugin_skill_client_upgrade_required"); + try { + if (request.params.name === "read_plugin_skill") { + const args = request.params.arguments ?? {}; + if (typeof args.pluginId !== "string" || typeof args.skillId !== "string") return toolError("plugin_skill_invalid"); + const ref = skillContent.reference(args.pluginId, args.skillId); + // Verify authorization and availability now, but never return instructions as tool content. + await skillContent.resolve(ref, extra.signal); + return toolResult({ kind: "memmy_plugin_skill_ref_v1", ref }); + } + const ref = SkillRefSchema.parse(request.params.arguments?.ref); + const current = skillContent.reference(ref.pluginId, ref.skillId); + if (JSON.stringify(current) !== JSON.stringify(ref)) { + return { content: [], _meta: { "memmy.dev/private-skill-context": { ref: current, reloadRequired: true } } }; + } + const instructions = await skillContent.resolve(ref, extra.signal); + // Not listed as a model tool. The runtime consumes this transient envelope directly. + return { content: [], _meta: { "memmy.dev/private-skill-context": { ref, instructions } } }; + } catch { return toolError("plugin_skill_load_failed: sign in, check connectivity and installed plugin version, then retry"); } + } + const tool = capabilityTools(plugins).find((candidate) => candidate.name === request.params.name); + if (!tool) return toolError(`Plugin capability is not active: ${request.params.name}`); + if (tool.plugin.manifest.skills?.some(skill => skill.cloud) && extra._meta?.["memmy.dev/cloud-skills"] !== "1") return toolError("plugin_skill_client_upgrade_required"); + if (tool.plugin.manifest.skills?.some(skill => skill.cloud)) { + try { + const ready = JSON.parse(metaString(extra._meta, "memmy.dev/private-skills-ready") ?? "[]"); + for (const skill of tool.plugin.manifest.skills.filter(skill => skill.cloud)) { + const current = skillContent?.reference(tool.plugin.id, skill.id); + if (!current || !Array.isArray(ready) || !ready.some(item => { + const parsed = SkillRefSchema.safeParse(item); + return parsed.success && JSON.stringify(parsed.data) === JSON.stringify(current); + })) return toolError("plugin_skill_not_loaded: activate the Skill and wait for the next model request"); + } + } catch { return toolError("plugin_skill_not_loaded"); } + } + const callId = randomUUID(); + const conversationId = metaString(extra._meta, "memmy.dev/session-key") ?? `mcp:${callId}`; + const modelPreset = metaString(extra._meta, "memmy.dev/model-preset"); + const input = tool.wrapsInput ? request.params.arguments?.input : (request.params.arguments ?? {}); + const call: CapabilityCall = { + callId, + pluginId: tool.plugin.id, + capabilityId: tool.capability.id, + conversationId, + ...(modelPreset ? { modelPreset } : {}), + input + }; + const provenance = { + ...(metaString(extra._meta, "memmy.dev/tool-call-id") ? { agentToolCallId: metaString(extra._meta, "memmy.dev/tool-call-id")! } : {}), + ...(metaString(extra._meta, "memmy.dev/turn-id") ? { turnId: metaString(extra._meta, "memmy.dev/turn-id")! } : {}), + }; + let terminal = false; + let retired = false; + let publishedEvent = false; + const publishedFiles: Array<{ name: string; path: string; mediaType: string }> = []; + const retire = (cancelled: boolean) => { + if (terminal || retired) return; + retired = true; + if (!publishedEvent) return; + progressBus.emit("plugin.capability_event", { + pluginId: tool.plugin.id, capabilityId: tool.capability.id, callId, conversationId, origin: "agent", ...provenance, + event: { type: "error", code: cancelled ? "plugin_call_cancelled" : "plugin_runtime_error", + message: cancelled ? "Plugin call was cancelled" : "Plugin call ended unexpectedly", retryable: false }, + }); + }; + const cancel = () => { + retire(true); + void plugins.cancel(tool.plugin.id, callId).catch(() => undefined); + }; + const progressToken = extra._meta?.progressToken; + let progressSequence = 0; + const sendTransportProgress = (message: string, current?: number, total?: number) => { + if (progressToken === undefined) return Promise.resolve(); + progressSequence += 1; + return extra.sendNotification({ + method: "notifications/progress", + params: { + progressToken, + progress: current ?? progressSequence, + ...(total === undefined ? {} : { total }), + message + } + }).catch(() => undefined); + }; + const keepalive = setInterval(() => { + void sendTransportProgress("Waiting for plugin interaction"); + }, keepaliveIntervalMs); + keepalive.unref?.(); + extra.signal.addEventListener("abort", cancel, { once: true }); + const artifacts: PluginArtifactRef[] = []; + try { + for await (const event of plugins.invoke(call)) { + if (extra.signal.aborted) break; + publishedEvent = true; + progressBus.emit("plugin.capability_event", { + pluginId: tool.plugin.id, + capabilityId: tool.capability.id, + callId, + conversationId, + // This route is the Agent's own way into a capability, so its cards + // are part of a turn and closing one has to answer rather than + // silently leave the turn waiting. + origin: "agent", + ...provenance, + event + }); + if (event.type === "artifact" && event.artifact.role !== "setup" && event.artifact.path) { + const file = { name: event.artifact.name, path: event.artifact.path, mediaType: event.artifact.mediaType }; + if (!publishedFiles.some((item) => item.path === file.path)) publishedFiles.push(file); + } + await sendTransportProgress( + event.type === "progress" ? event.message ?? "Plugin progress" : `Plugin ${event.type}`, + event.type === "progress" ? event.current : undefined, + event.type === "progress" ? event.total : undefined + ); + if (event.type === "artifact") artifacts.push(event.artifact); + if (event.type === "result") { + terminal = true; + const visible = modelVisiblePluginResult(event.output, publishedFiles); + const output = artifacts.length + ? visible && typeof visible === "object" && !Array.isArray(visible) + ? { ...visible as Record, artifacts } : { output: visible, artifacts } + : visible; + const result = toolResult(output); + // QA pages must reach the model as pixels, not merely a download URL. + // Resolve only Host-issued artifact tokens; never fetch plugin URLs. + const images: Array<{ type: "image"; mimeType: string; data: string }> = []; + let imageBytes = 0; + if (plugins.openArtifact) for (const artifact of artifacts) { + if (artifact.role !== "setup" || artifact.mediaType !== "image/png") continue; + const token = artifact.uri.match(/^\/api\/v1\/plugins\/[^/]+\/artifacts\/([a-f0-9-]+)\/preview$/)?.[1]; + if (!token) continue; + const hosted = await plugins.openArtifact(tool.plugin.id, token); + const info = await stat(hosted.path); + if (info.size > 8 * 1024 * 1024 - imageBytes || images.length >= 4) return toolError("Too many QA images in one call; request one page at a time"); + const bytes = await readFile(hosted.path); + if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return toolError("QA artifact is not a valid PNG image"); + imageBytes += bytes.byteLength; + if (images.length >= 4 || imageBytes > 8 * 1024 * 1024) return toolError("Too many QA images in one call; request one page at a time"); + images.push({ type: "image", mimeType: "image/png", data: bytes.toString("base64") }); + } + return { ...result, content: [...result.content, ...images] }; + } + if (event.type === "error") { terminal = true; return toolError(`${event.code}: ${event.message}`); } + } + return toolError("Plugin ended without a terminal event"); + } catch (error) { + return toolError(error instanceof Error ? error.message : String(error)); + } finally { + clearInterval(keepalive); + extra.signal.removeEventListener("abort", cancel); + retire(extra.signal.aborted); + } + }); + + return server; +} + +/** Preserve the plugin result while exposing its produced files to the main Agent. */ +function modelVisiblePluginResult( + output: unknown, + publishedFiles: Array<{ name: string; path: string; mediaType: string }> +): unknown { + if (!publishedFiles.length) return output; + const value: Record = output && typeof output === "object" && !Array.isArray(output) + ? { ...output as Record } + : { output }; + return { ...value, publishedFiles }; +} + +export function registerPluginMcpRoutes(app: FastifyInstance, options: RegisterPluginMcpRoutesOptions): void { + // SDK cancellation is a separate HTTP notification. It must reach the same + // Server that owns the in-flight tools/call, rather than a fresh stateless + // Server whose request-controller map is empty. + const sessions = new Map(); + const cleanup = setInterval(() => { + for (const [id, session] of sessions) { + if (session.active === 0 && Date.now() - session.lastUsed > 30 * 60_000) { + sessions.delete(id); + void session.server.close(); + } + } + }, 60_000); + cleanup.unref(); + app.addHook("preClose", async () => { + clearInterval(cleanup); + await Promise.all([...sessions.values()].map(session => session.server.close())); + sessions.clear(); + }); + const handler = async (request: FastifyRequest, reply: FastifyReply): Promise => { + if (singleHeader(request.headers[MCP_TOKEN_HEADER]) !== options.mcpToken) { + await reply.code(401).send({ error: "unauthorized" }); + return; + } + + const sessionId = singleHeader(request.headers["mcp-session-id"]); + if (sessionId && !sessions.has(sessionId)) { + await reply.code(404).send({ error: "MCP session not found" }); + return; + } + let session = sessionId ? sessions.get(sessionId) : undefined; + if (!session && request.method === "POST" && isInitializeRequest(request.body)) { + const id = randomUUID(); + const server = buildPluginMcpServer(options.plugins, options.progressBus, PLUGIN_MCP_KEEPALIVE_MS, options.skillContent); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => id }); + session = { server, transport, active: 0, lastUsed: Date.now() }; + transport.onclose = () => { sessions.delete(id); }; + await server.connect(transport); + sessions.set(id, session); + } + if (session) { + const current = session; + current.active += 1; + reply.hijack(); + reply.raw.once("close", () => { current.active -= 1; current.lastUsed = Date.now(); }); + await current.transport.handleRequest(request.raw, reply.raw, request.body); + return; + } + // Preserve one-shot raw callers that do not initialize an MCP session. + const server = buildPluginMcpServer(options.plugins, options.progressBus, PLUGIN_MCP_KEEPALIVE_MS, options.skillContent); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + reply.hijack(); + reply.raw.on("close", () => { + void transport.close(); + void server.close(); + }); + await server.connect(transport); + await transport.handleRequest(request.raw, reply.raw, request.body); + }; + + app.post(MCP_ROUTE_PATH, handler); + app.get(MCP_ROUTE_PATH, handler); + app.delete(MCP_ROUTE_PATH, handler); +} + +function capabilityTools(plugins: PluginMcpService): CapabilityTool[] { + return plugins.list() + .filter((plugin) => plugin.state === "active" && plugin.eligibility?.canUse !== false) + .flatMap((plugin) => plugin.manifest.capabilities.map((capability) => ({ + name: capabilityToolName(plugin.id, capability.id), + plugin, + capability, + wrapsInput: capability.inputSchema.type !== "object" + }))); +} + +export function capabilityToolName(pluginId: string, capabilityId: string): string { + // The Agent prefixes MCP names with `mcp_plugins_` and caps them at 64 + // characters. Keep this raw name within 52 characters so the capability ID + // remains readable instead of collapsing several tools to `review_update__*`. + const digest = createHash("sha256").update(`${pluginId}\0${capabilityId}`).digest("hex").slice(0, 8); + const readable = capabilityId.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_"); + const maxReadableLength = 52 - "plugin__".length - digest.length; + return `plugin_${readable.slice(0, maxReadableLength)}_${digest}`; +} + +function mcpInputSchema(schema: JsonSchema, wrapsInput: boolean): Record { + return wrapsInput + ? { type: "object", properties: { input: schema }, required: ["input"], additionalProperties: false } + : { ...schema, type: "object" }; +} + +function metaString(meta: Record | undefined, key: string): string | undefined { + const value = meta?.[key]; + return typeof value === "string" && value.length ? value : undefined; +} + +function toolResult(output: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(output) ?? "null" }], + ...(output && typeof output === "object" && !Array.isArray(output) + ? { structuredContent: output as Record } + : {}) + }; +} + +function toolError(message: string) { + return { content: [{ type: "text" as const, text: message }], isError: true }; +} + +function singleHeader(header: string | string[] | undefined): string | undefined { + return Array.isArray(header) ? header[0] : header; +} diff --git a/App/backend/src/adapters/inbound/local-api/routes/plugins.ts b/App/backend/src/adapters/inbound/local-api/routes/plugins.ts new file mode 100644 index 000000000..e4ce2c7f7 --- /dev/null +++ b/App/backend/src/adapters/inbound/local-api/routes/plugins.ts @@ -0,0 +1,188 @@ +/** Third-party plugin management and capability routes. */ +import { + InstallPluginInputSchema, + PluginCatalogSchema, + InstalledPluginSchema, + InvokePluginCapabilityInputSchema, + PluginInteractionResponseInputSchema, + PluginUiRendererResponseSchema, + PluginUiSlotSchema, + UpdatePluginConfigInputSchema, + UpdatePluginInputSchema, + UpdatePluginPermissionsInputSchema, + type CapabilityEvent +} from "@memmy/local-api-contracts"; +import { randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { z } from "zod"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { PluginService } from "../../../../services/plugin-service.js"; +import type { ProgressBus } from "../../../../services/progress-bus.js"; +import { withErrorEnvelope } from "../../../../services/error-envelope.js"; + +export interface RegisterPluginRoutesOptions { + plugins: PluginService; + progressBus: ProgressBus; + authenticateRuntimeToken: (request: FastifyRequest, reply: FastifyReply) => Promise; + refreshAgentTools?: () => Promise; +} + +const PluginParamsSchema = z.object({ id: z.string().trim().min(1).max(128) }); +const CapabilityParamsSchema = PluginParamsSchema.extend({ capabilityId: z.string().trim().min(1).max(128) }); +const CallParamsSchema = PluginParamsSchema.extend({ callId: z.string().trim().min(1) }); +const InteractionParamsSchema = CallParamsSchema.extend({ interactionId: z.string().trim().min(1) }); +const UiParamsSchema = PluginParamsSchema.extend({ slot: PluginUiSlotSchema }); +const ArtifactParamsSchema = PluginParamsSchema.extend({ token: z.string().uuid() }); + +export function registerPluginRoutes(app: FastifyInstance, options: RegisterPluginRoutesOptions): void { + const protectedRoute = { preHandler: options.authenticateRuntimeToken }; + + app.get("/api/v1/plugins/catalog", protectedRoute, withErrorEnvelope(async (_request, reply) => + reply.send(PluginCatalogSchema.parse(await options.plugins.catalog())) + )); + + app.post("/api/v1/plugins/install", protectedRoute, withErrorEnvelope(async (request, reply) => { + const input = InstallPluginInputSchema.parse(request.body); + const plugin = InstalledPluginSchema.parse(await options.plugins.install(input.pluginId, input.version)); + await refreshAgentTools(options); + return reply.code(201).send(plugin); + })); + + app.get("/api/v1/plugins", protectedRoute, withErrorEnvelope(async (_request, reply) => + reply.send(z.array(InstalledPluginSchema).parse(options.plugins.list())) + )); + + app.get("/api/v1/plugins/:id", protectedRoute, withErrorEnvelope(async (request, reply) => { + const { id } = PluginParamsSchema.parse(request.params); + return reply.send(InstalledPluginSchema.parse(options.plugins.get(id))); + })); + + app.get("/api/v1/plugins/:id/ui/:slot", protectedRoute, withErrorEnvelope(async (request, reply) => { + const { id, slot } = UiParamsSchema.parse(request.params); + return reply.send(PluginUiRendererResponseSchema.parse({ html: await options.plugins.readUi(id, slot) })); + })); + + const sendArtifact = async (request: FastifyRequest, reply: FastifyReply, disposition: "inline" | "attachment") => { + const { id, token } = ArtifactParamsSchema.parse(request.params); + const artifact = await options.plugins.openArtifact(id, token); + return reply + .type(artifact.mediaType) + .header("content-disposition", contentDisposition(disposition, artifact.name)) + .header("content-security-policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:") + .header("x-content-type-options", "nosniff") + .send(createReadStream(artifact.path)); + }; + app.get("/api/v1/plugins/:id/artifacts/:token/preview", protectedRoute, withErrorEnvelope((request, reply) => sendArtifact(request, reply, "inline"))); + app.get("/api/v1/plugins/:id/artifacts/:token/download", protectedRoute, withErrorEnvelope((request, reply) => sendArtifact(request, reply, "attachment"))); + + app.put("/api/v1/plugins/:id/config", protectedRoute, withErrorEnvelope(async (request, reply) => { + const { id } = PluginParamsSchema.parse(request.params); + return reply.send(InstalledPluginSchema.parse(options.plugins.configure(id, UpdatePluginConfigInputSchema.parse(request.body)))); + })); + + app.put("/api/v1/plugins/:id/permissions", protectedRoute, withErrorEnvelope(async (request, reply) => { + const { id } = PluginParamsSchema.parse(request.params); + const input = UpdatePluginPermissionsInputSchema.parse(request.body); + const plugin = InstalledPluginSchema.parse(await options.plugins.approvePermissions(id, input.permissions)); + await refreshAgentTools(options); + return reply.send(plugin); + })); + + app.post("/api/v1/plugins/:id/enable", protectedRoute, withErrorEnvelope(async (request, reply) => { + const { id } = PluginParamsSchema.parse(request.params); + const plugin = InstalledPluginSchema.parse(await options.plugins.enable(id)); + await refreshAgentTools(options); + return reply.send(plugin); + })); + + app.post("/api/v1/plugins/:id/disable", protectedRoute, withErrorEnvelope(async (request, reply) => { + const { id } = PluginParamsSchema.parse(request.params); + const plugin = InstalledPluginSchema.parse(await options.plugins.disable(id)); + await refreshAgentTools(options); + return reply.send(plugin); + })); + + app.post("/api/v1/plugins/:id/update", protectedRoute, withErrorEnvelope(async (request, reply) => { + const { id } = PluginParamsSchema.parse(request.params); + const input = UpdatePluginInputSchema.parse(request.body ?? {}); + const plugin = InstalledPluginSchema.parse(await options.plugins.update(id, input.version)); + await refreshAgentTools(options); + return reply.send(plugin); + })); + + app.delete("/api/v1/plugins/:id", protectedRoute, withErrorEnvelope(async (request, reply) => { + const { id } = PluginParamsSchema.parse(request.params); + await options.plugins.uninstall(id); + await refreshAgentTools(options); + return reply.send({ ok: true }); + })); + + app.post("/api/v1/plugins/:id/capabilities/:capabilityId/invoke", protectedRoute, + withErrorEnvelope(async (request, reply) => { + const { id, capabilityId } = CapabilityParamsSchema.parse(request.params); + const input = InvokePluginCapabilityInputSchema.parse(request.body); + const callId = randomUUID(); + const cancel = () => void options.plugins.cancel(id, callId).catch(() => undefined); + request.raw.once("aborted", cancel); + let terminal: CapabilityEvent | undefined; + try { + for await (const event of options.plugins.invoke({ + callId, + pluginId: id, + capabilityId, + conversationId: input.conversationId, + input: input.input, + deadline: input.deadline + })) { + options.progressBus.emit("plugin.capability_event", { + pluginId: id, + capabilityId, + callId, + conversationId: input.conversationId, + origin: input.origin, + event + }); + if (event.type === "result" || event.type === "error") terminal = event; + } + } finally { + request.raw.off("aborted", cancel); + } + if (!terminal) throw Object.assign(new Error("Plugin ended without a terminal event"), { code: "plugin_runtime_error" }); + return reply.send({ callId, event: terminal }); + }) + ); + + app.post("/api/v1/plugins/:id/calls/:callId/cancel", protectedRoute, + withErrorEnvelope(async (request, reply) => { + const { id, callId } = CallParamsSchema.parse(request.params); + await options.plugins.cancel(id, callId); + return reply.send({ ok: true }); + }) + ); + + app.post("/api/v1/plugins/:id/calls/:callId/interactions/:interactionId", protectedRoute, + withErrorEnvelope(async (request, reply) => { + 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 }); + }) + ); +} + +function contentDisposition(disposition: "inline" | "attachment", name: string): string { + const fallback = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 128) || "artifact"; + return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`; +} + +async function refreshAgentTools(options: RegisterPluginRoutesOptions): Promise { + try { + await options.refreshAgentTools?.(); + } catch (error) { + console.warn(`Agent plugin tool reload unavailable: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/App/backend/src/adapters/inbound/local-api/server.ts b/App/backend/src/adapters/inbound/local-api/server.ts index c737a4f8c..2a7ab9026 100644 --- a/App/backend/src/adapters/inbound/local-api/server.ts +++ b/App/backend/src/adapters/inbound/local-api/server.ts @@ -18,6 +18,8 @@ import { registerComposioMcpRoutes } from "./routes/composio-mcp.js"; import { registerIntegrationRoutes } from "./routes/integrations.js"; import { registerLocalDataRoutes } from "./routes/local-data.js"; import { registerOnboardingInsightRoutes } from "./routes/onboarding-insight.js"; +import { registerPluginRoutes } from "./routes/plugins.js"; +import { registerPluginMcpRoutes } from "./routes/plugin-mcp.js"; const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000; const LOCAL_API_SERVICE_NAME = "memmy-local-api"; @@ -35,7 +37,7 @@ export interface CreateLocalApiServerOptions { /** Configured agent timezone. Renderer headers are used only when absent. */ timeZone?: string; /** - * Local validation token for the Composio MCP bridge; the agent carries it in mcpServers.composio.headers to access /mcp/composio. + * Local validation token shared by Memmy's loopback MCP bridges. */ composioMcpToken: string; heartbeatIntervalMs?: number; @@ -43,6 +45,7 @@ export interface CreateLocalApiServerOptions { scanProcess?: { databasePath: string; }; + pluginCapabilitiesChanged?: () => Promise; } interface EventsQuerystring { @@ -119,6 +122,12 @@ export function createLocalApiServer(options: CreateLocalApiServerOptions): Fast integrations: options.services.integrations, mcpToken: options.composioMcpToken }); + registerPluginMcpRoutes(app, { + skillContent: options.services.pluginSkillContent, + plugins: options.services.plugins, + progressBus: options.services.progressBus, + mcpToken: options.composioMcpToken + }); registerChannelRoutes(app, { channels: options.services.channels, authenticateRuntimeToken @@ -140,6 +149,12 @@ export function createLocalApiServer(options: CreateLocalApiServerOptions): Fast permissionManager: options.permissionManager, authenticateRuntimeToken }); + registerPluginRoutes(app, { + plugins: options.services.plugins, + progressBus: options.services.progressBus, + authenticateRuntimeToken, + refreshAgentTools: options.pluginCapabilitiesChanged + }); registerAgentRuntimeRoutes(app, { services: options.services, timeZone: options.timeZone, @@ -156,9 +171,57 @@ export function createLocalApiServer(options: CreateLocalApiServerOptions): Fast startSse(reply, heartbeatIntervalMs, getSingleHeaderValue(request.headers.origin), options.services.progressBus); }); + // The live ASR stream is a WebSocket, which Fastify's router never sees: an + // Upgrade request bypasses routing and lands on the raw HTTP server. The + // handshake is authenticated with the same runtime token the SSE channel + // takes as a query parameter, for the same reason — a browser WebSocket + // cannot carry custom headers. Origin is checked with the HTTP rules so a + // page that could not fetch the API cannot open a socket to it either. + app.server.on("upgrade", (request, socket, head) => { + const url = new URL(request.url ?? "/", "http://localhost"); + if (url.pathname !== ASR_STREAM_PATH) { + socket.destroy(); + return; + } + const origin = getSingleHeaderValue(request.headers.origin); + if (origin && !isAllowedOrigin(origin, options.allowedOrigins)) { + rejectUpgrade(socket, 403); + return; + } + const token = url.searchParams.get("token"); + void (async () => { + if (!token || !(await options.permissionManager.verifyRuntimeToken(token))) { + rejectUpgrade(socket, 401); + return; + } + const relay = options.services.asrStream; + if (!relay) { + // Same partial-services allowance as the close hook: a test server + // without the relay answers 404 instead of throwing on a live socket. + rejectUpgrade(socket, 404); + return; + } + relay.handleUpgrade(request, socket, head); + })(); + }); + app.addHook("onClose", async () => { + // Route tests build a partial services object; the relay is only there in + // the real wiring, so its absence must not fail teardown. + await options.services.asrStream?.close(); + }); + return app; } +const ASR_STREAM_PATH = "/api/asr/stream"; + +/** Answers a rejected upgrade with a bare HTTP status before the socket is dropped. */ +function rejectUpgrade(socket: import("node:stream").Duplex, status: 401 | 403 | 404): void { + const text = status === 401 ? "Unauthorized" : status === 403 ? "Forbidden" : "Not Found"; + socket.write(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); + socket.destroy(); +} + function createRuntimeTokenPreHandler(permissionManager: PermissionManager) { return async (request: FastifyRequest, reply: FastifyReply) => { if (!(await isAuthorized(request, permissionManager))) { @@ -285,12 +348,27 @@ function startSse( payload: event }); }); + const unsubscribePluginCapability = progressBus.on("plugin.capability_event", (event) => { + send({ + id: randomUUID(), + type: "plugin.capability_event", + timestamp: new Date().toISOString(), + payload: event + }); + }); + for (const payload of progressBus.pendingPluginEvents()) { + send({ id: randomUUID(), type: "plugin.capability_event", timestamp: new Date().toISOString(), payload }); + } + const pendingCalls = [...new Map(progressBus.pendingPluginEvents().map(({ pluginId, capabilityId, callId, conversationId }) => + [JSON.stringify([pluginId, capabilityId, callId, conversationId]), { pluginId, capabilityId, callId, conversationId }])).values()]; + send({ id: randomUUID(), type: "plugin.capability_snapshot", timestamp: new Date().toISOString(), payload: { calls: pendingCalls } }); const cleanup = () => { // Clean up the heartbeat timer promptly after the client disconnects, avoiding orphaned tasks lingering in the background. clearInterval(interval); unsubscribeScanProgress(); unsubscribeScanCompleted(); + unsubscribePluginCapability(); }; reply.raw.once("close", cleanup); diff --git a/App/backend/src/adapters/inbound/local-api/tests/asr-route.test.ts b/App/backend/src/adapters/inbound/local-api/tests/asr-route.test.ts new file mode 100644 index 000000000..46e1ff9be --- /dev/null +++ b/App/backend/src/adapters/inbound/local-api/tests/asr-route.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { ASR_MAX_AUDIO_BYTES } from "@memmy/local-api-contracts"; +import type { PermissionManager } from "../../../../permission/index.js"; +import { createProgressBus } from "../../../../services/progress-bus.js"; +import type { BackendServices } from "../../../../services/index.js"; +import { createLocalApiServer } from "../server.js"; + +let app: FastifyInstance | undefined; + +afterEach(async () => { + await app?.close(); + app = undefined; +}); + +describe("asr local api route", () => { + it("accepts a recording far larger than fastify's default body limit", async () => { + // An interview has to reach the route as one request, and Fastify's 1 MiB + // default only fits a couple of minutes of speech. + const transcribe = vi.fn(async () => ({ + text: "转写结果", + modelId: "test-model", + provider: "dashscope" as const, + source: "account" as const, + transcribedAt: new Date().toISOString() + })); + app = createServer(transcribe); + + const audioBase64 = "A".repeat(4 * 1024 * 1024); + const response = await app.inject({ + method: "POST", + url: "/api/asr/transcriptions", + headers: { "x-memmy-local-token": "test-token" }, + payload: { audioBase64, mimeType: "audio/webm" } + }); + + expect(response.statusCode).toBe(200); + expect(transcribe).toHaveBeenCalledOnce(); + }); + + it("rejects audio beyond the transcription ceiling instead of forwarding it", async () => { + const transcribe = vi.fn(); + app = createServer(transcribe as never); + + const response = await app.inject({ + method: "POST", + url: "/api/asr/transcriptions", + headers: { "x-memmy-local-token": "test-token" }, + payload: { + audioBase64: "A".repeat(Math.ceil((ASR_MAX_AUDIO_BYTES * 4) / 3) + 1), + mimeType: "audio/webm" + } + }); + + expect(response.statusCode).toBe(400); + expect(transcribe).not.toHaveBeenCalled(); + }); + + it("passes diarization through to the transcription service and returns its segments", async () => { + const transcribe = vi.fn(async () => ({ + text: "甲说完乙说", + modelId: "test-model", + provider: "dashscope" as const, + source: "account" as const, + transcribedAt: new Date().toISOString(), + segments: [ + { text: "甲说", speakerId: 0, startMs: 0, endMs: 500 }, + { text: "乙说", speakerId: 1, startMs: 500, endMs: 900 } + ] + })); + app = createServer(transcribe); + + const response = await app.inject({ + method: "POST", + url: "/api/asr/transcriptions", + headers: { "x-memmy-local-token": "test-token" }, + payload: { + audioBase64: "UklGRg==", + mimeType: "audio/wav", + diarization: true + } + }); + + expect(response.statusCode).toBe(200); + expect(transcribe).toHaveBeenCalledWith(expect.objectContaining({ diarization: true })); + expect(response.json().segments).toEqual([ + { text: "甲说", speakerId: 0, startMs: 0, endMs: 500 }, + { text: "乙说", speakerId: 1, startMs: 500, endMs: 900 } + ]); + }); +}); + +function createServer(transcribe: BackendServices["asr"]["transcribe"]): FastifyInstance { + return createLocalApiServer({ + permissionManager: createPermissionManager(), + services: { + progressBus: createProgressBus(), + asr: { transcribe } + } as unknown as BackendServices, + heartbeatIntervalMs: 20 + }); +} + +function createPermissionManager(): PermissionManager { + return { + async getRuntimeToken() { + return "test-token"; + }, + async verifyRuntimeToken(token) { + return token === "test-token"; + }, + async getScanPermission() { + return "none"; + }, + async setScanPermission() { + return undefined; + }, + async canDetectAgentSources() { + return true; + }, + async canScanAgentSource() { + return false; + }, + async canWriteAgentSkill() { + return false; + }, + async canSearchMemory() { + return true; + }, + async revokeAgentSource() { + return undefined; + } + }; +} diff --git a/App/backend/src/adapters/inbound/local-api/tests/asr-stream-upgrade.test.ts b/App/backend/src/adapters/inbound/local-api/tests/asr-stream-upgrade.test.ts new file mode 100644 index 000000000..c5c78606e --- /dev/null +++ b/App/backend/src/adapters/inbound/local-api/tests/asr-stream-upgrade.test.ts @@ -0,0 +1,145 @@ +/** Asr stream upgrade tests. */ +import { request as httpRequest, type IncomingMessage } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { FastifyInstance } from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createProgressBus } from "../../../../services/progress-bus.js"; +import type { BackendServices } from "../../../../services/index.js"; +import type { PermissionManager } from "../../../../permission/index.js"; +import { createLocalApiServer } from "../server.js"; + +let app: FastifyInstance | undefined; + +afterEach(async () => { + await app?.close(); + app = undefined; +}); + +/** + * Sends a raw WebSocket handshake and reports how the server answered. + * + * A real `ws` client would swallow the rejection into a generic error, and + * the status line is exactly what this test is about. + */ +async function attemptUpgrade( + server: FastifyInstance, + path: string, + headers: Record = {} +): Promise<{ status: number; upgraded: boolean }> { + const { port } = server.server.address() as AddressInfo; + return new Promise((resolve, reject) => { + const req = httpRequest({ + host: "127.0.0.1", + port, + path, + headers: { + connection: "Upgrade", + upgrade: "websocket", + "sec-websocket-version": "13", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + ...headers + } + }); + req.on("upgrade", (response: IncomingMessage, socket) => { + socket.destroy(); + resolve({ status: response.statusCode ?? 101, upgraded: true }); + }); + req.on("response", (response: IncomingMessage) => { + response.resume(); + resolve({ status: response.statusCode ?? 0, upgraded: false }); + }); + req.on("error", reject); + req.end(); + }); +} + +describe("asr stream upgrade", () => { + it("rejects an upgrade without the runtime token before touching the relay", async () => { + const handleUpgrade = vi.fn(); + app = createServer(handleUpgrade); + await app.listen({ host: "127.0.0.1", port: 0 }); + + const result = await attemptUpgrade(app, "/api/asr/stream"); + + expect(result).toEqual({ status: 401, upgraded: false }); + expect(handleUpgrade).not.toHaveBeenCalled(); + }); + + it("rejects an upgrade carrying a wrong token", async () => { + const handleUpgrade = vi.fn(); + app = createServer(handleUpgrade); + await app.listen({ host: "127.0.0.1", port: 0 }); + + const result = await attemptUpgrade(app, "/api/asr/stream?token=nope"); + + expect(result).toEqual({ status: 401, upgraded: false }); + expect(handleUpgrade).not.toHaveBeenCalled(); + }); + + it("applies the same origin rule as HTTP so a foreign page cannot open the socket", async () => { + const handleUpgrade = vi.fn(); + app = createServer(handleUpgrade); + await app.listen({ host: "127.0.0.1", port: 0 }); + + const result = await attemptUpgrade(app, "/api/asr/stream?token=test-token", { origin: "https://evil.example" }); + + expect(result).toEqual({ status: 403, upgraded: false }); + expect(handleUpgrade).not.toHaveBeenCalled(); + }); + + it("hands a correctly authenticated upgrade to the relay", async () => { + // The stub completes the handshake itself so the client observes a real + // 101 rather than a torn-down socket; what this asserts is that the relay + // was reached with the original request. + const handleUpgrade = vi.fn((_request: unknown, socket: { write(chunk: string): void; destroy(): void }) => { + socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"); + socket.destroy(); + }); + app = createServer(handleUpgrade); + await app.listen({ host: "127.0.0.1", port: 0 }); + + const result = await attemptUpgrade(app, "/api/asr/stream?token=test-token", { origin: "http://127.0.0.1:19100" }); + + expect(result).toEqual({ status: 101, upgraded: true }); + expect(handleUpgrade).toHaveBeenCalledTimes(1); + const [request] = handleUpgrade.mock.calls[0] as [IncomingMessage]; + expect(request.url).toBe("/api/asr/stream?token=test-token"); + }); + + it("drops upgrades for any other path so unrelated sockets never reach the relay", async () => { + const handleUpgrade = vi.fn(); + app = createServer(handleUpgrade); + await app.listen({ host: "127.0.0.1", port: 0 }); + + await expect(attemptUpgrade(app, "/api/events?token=test-token")).rejects.toThrow(); + expect(handleUpgrade).not.toHaveBeenCalled(); + }); +}); + +function createServer(handleUpgrade: (...args: unknown[]) => void): FastifyInstance { + return createLocalApiServer({ + permissionManager: createPermissionManager(), + composioMcpToken: "mcp-token", + services: { + progressBus: createProgressBus(), + asrStream: { + handleUpgrade, + async close() { + return undefined; + } + } + } as unknown as BackendServices, + heartbeatIntervalMs: 20 + }); +} + +function createPermissionManager(): PermissionManager { + return { + async getRuntimeToken() { + return "test-token"; + }, + async verifyRuntimeToken(token: string) { + return token === "test-token"; + } + } as PermissionManager; +} diff --git a/App/backend/src/adapters/inbound/local-api/tests/plugin-mcp.test.ts b/App/backend/src/adapters/inbound/local-api/tests/plugin-mcp.test.ts new file mode 100644 index 000000000..e07ca451a --- /dev/null +++ b/App/backend/src/adapters/inbound/local-api/tests/plugin-mcp.test.ts @@ -0,0 +1,301 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import Fastify from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { InstalledPlugin } from "@memmy/local-api-contracts"; +import { createProgressBus } from "../../../../services/progress-bus.js"; +import { buildPluginMcpServer, capabilityToolName, registerPluginMcpRoutes, type PluginMcpService } from "../routes/plugin-mcp.js"; + +const connections: Array<{ client: Client; server: ReturnType }> = []; + +afterEach(async () => { + await Promise.all(connections.splice(0).flatMap(({ client, server }) => [client.close(), server.close()])); +}); + +const plugin: InstalledPlugin = { + id: "com.example.review", + version: "1.0.0", + manifest: { + apiVersion: "memmy/v1", + id: "com.example.review", + name: "Review", + version: "1.0.0", + runtime: { adapter: "http", config: {} }, + capabilities: [{ + id: "run", + name: "Run review", + description: "Search papers and write a review", + inputSchema: { type: "object", properties: { topic: { type: "string" } }, required: ["topic"] }, + outputSchema: { type: "object" }, + execution: "job" + }], + permissions: [] + }, + state: "active", + approvedPermissions: [], + config: {}, + lastError: null, + createdAt: "2026-08-28T00:00:00.000Z", + updatedAt: "2026-08-28T00:00:00.000Z" +}; + +function createPlugins(state: InstalledPlugin["state"] = "active") { + const invoke = vi.fn(async function* () { + yield { type: "progress" as const, current: 1, total: 1 }; + yield { type: "result" as const, output: { review: "done" } }; + }); + const service: PluginMcpService = { + list: () => [{ ...plugin, state }], + invoke, + cancel: vi.fn(async () => undefined) + }; + return { service, invoke }; +} + +async function connect( + plugins: PluginMcpService, + onEvent?: (event: unknown) => void, + keepaliveIntervalMs?: number +): Promise { + const progressBus = createProgressBus(); + if (onEvent) progressBus.on("plugin.capability_event", onEvent); + const server = buildPluginMcpServer(plugins, progressBus, keepaliveIntervalMs); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "1.0.0" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + connections.push({ client, server }); + return client; +} + +describe("plugin MCP bridge", () => { + it("forwards tool and turn provenance for migrated process records", async () => { + const events: unknown[] = []; + const { service } = createPlugins(); + const client = await connect(service, (event) => events.push(event)); + const tools = await client.listTools(); + await client.callTool({ name: tools.tools[0]!.name, arguments: { topic: "Questions" }, + _meta: { "memmy.dev/session-key": "websocket:c1", "memmy.dev/tool-call-id": "tool-42", "memmy.dev/turn-id": "turn-7" } }); + expect(events.length).toBeGreaterThan(0); + for (const event of events) expect(event).toMatchObject({ + conversationId: "websocket:c1", agentToolCallId: "tool-42", turnId: "turn-7" + }); + }); + + it("returns plugin-produced file inventory without replacing the result", async () => { + const service: PluginMcpService = { + list: () => [plugin], + invoke: async function* () { + yield { type: "artifact" as const, artifact: { id: "report", name: "report.docx", + mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + uri: "/preview", path: "/workspace/report.docx", role: "deliverable" as const } }; + yield { type: "result" as const, output: { nextStep: "done", sessionId: "s1" } }; + }, + cancel: vi.fn(async () => undefined) + }; + const client = await connect(service); + const result = await client.callTool({ name: capabilityToolName(plugin.id, "run"), arguments: {} }); + expect(result.structuredContent).toMatchObject({ + publishedFiles: [{ name: "report.docx", path: "/workspace/report.docx" }], + nextStep: "done" + }); + }); + it("closes live HTTP MCP sessions before shutting down the Host", async () => { + const { service } = createPlugins(); + const app = Fastify(); + registerPluginMcpRoutes(app, { plugins: service, progressBus: createProgressBus(), mcpToken: "test-secret" }); + const url = await app.listen({ host: "127.0.0.1", port: 0 }); + const transport = new StreamableHTTPClientTransport(new URL("/mcp/plugins", url), { + requestInit: { headers: { "x-memmy-mcp-token": "test-secret" } } + }); + const client = new Client({ name: "shutdown-test", version: "1" }); + try { + await client.connect(transport); + await expect(client.listTools()).resolves.toMatchObject({ tools: expect.any(Array) }); + await app.close(); + } finally { + await client.close(); + await app.close(); + } + }); + + it("routes HTTP cancellation to the owning request without cancelling another client's matching request ID", async () => { + const calls: string[] = []; + const releases = new Map void>(); + const plugins: PluginMcpService = { + list: () => [plugin], + invoke: async function* (call) { + calls.push(call.callId); + await new Promise(resolve => { releases.set(call.callId, resolve); }); + yield { type: "result", output: { done: true } }; + }, + cancel: vi.fn(async (_pluginId, callId) => { releases.get(callId)?.(); }) + }; + const app = Fastify(); + registerPluginMcpRoutes(app, { plugins, progressBus: createProgressBus(), mcpToken: "test-secret" }); + const url = await app.listen({ host: "127.0.0.1", port: 0 }); + const transports = [0, 1].map(() => new StreamableHTTPClientTransport(new URL("/mcp/plugins", url), { + requestInit: { headers: { "x-memmy-mcp-token": "test-secret" } } + })); + const clients = [0, 1].map(() => new Client({ name: "cancellation-test", version: "1" })); + try { + await Promise.all(clients.map((client, i) => client.connect(transports[i]!))); + expect(transports[0]!.sessionId).toBeTruthy(); + expect(transports[0]!.sessionId).not.toBe(transports[1]!.sessionId); + const abort = new AbortController(); + const first = clients[0]!.callTool({ name: capabilityToolName(plugin.id, "run"), arguments: { topic: "first" } }, undefined, { signal: abort.signal }); + const rejected = expect(first).rejects.toBeDefined(); + await vi.waitFor(() => expect(calls).toHaveLength(1)); + let secondFinished = false; + const second = clients[1]!.callTool({ name: capabilityToolName(plugin.id, "run"), arguments: { topic: "second" } }).then(result => { secondFinished = true; return result; }); + await vi.waitFor(() => expect(calls).toHaveLength(2)); + abort.abort(); + await rejected; + await vi.waitFor(() => expect(plugins.cancel).toHaveBeenCalledExactlyOnceWith(plugin.id, calls[0])); + expect(secondFinished).toBe(false); + releases.get(calls[1]!)!(); + await expect(second).resolves.toMatchObject({ structuredContent: { done: true } }); + } finally { + for (const release of releases.values()) release(); + await Promise.all(transports.map(transport => transport.terminateSession())); + await Promise.all(clients.map(client => client.close())); + await app.close(); + } + }); + + it("keeps similarly prefixed capability names readable after the Agent MCP prefix is added", () => { + const updateSelection = capabilityToolName("literature-review", "review_update_paper_selection"); + const resolveMetadata = capabilityToolName("literature-review", "review_resolve_metadata"); + + expect(updateSelection).toMatch(/^plugin_review_update_paper_selection_[a-f0-9]{8}$/); + expect(resolveMetadata).toMatch(/^plugin_review_resolve_metadata_[a-f0-9]{8}$/); + expect(updateSelection).not.toBe(resolveMetadata); + expect(`mcp_plugins_${updateSelection}`.length).toBeLessThanOrEqual(64); + expect(`mcp_plugins_${resolveMetadata}`.length).toBeLessThanOrEqual(64); + }); + + it("exposes and invokes each active capability with the current conversation", async () => { + const events: unknown[] = []; + const { service, invoke } = createPlugins(); + const client = await connect(service, (event) => events.push(event)); + const tools = await client.listTools(); + + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0]?.description).toContain("Search papers"); + const result = await client.callTool({ + name: tools.tools[0]!.name, + arguments: { topic: "Agent Memory" }, + _meta: { + "memmy.dev/session-key": "desktop:conversation-1", + "memmy.dev/model-preset": "account-deepseek-v4.1-flash" + } + }); + + expect(result).toMatchObject({ structuredContent: { review: "done" } }); + expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ + pluginId: plugin.id, + capabilityId: "run", + conversationId: "desktop:conversation-1", + modelPreset: "account-deepseek-v4.1-flash", + input: { topic: "Agent Memory" } + })); + expect(events).toHaveLength(2); + }); + + it("does not expose disabled plugin capabilities", async () => { + const client = await connect(createPlugins("disabled").service); + expect((await client.listTools()).tools).toEqual([]); + }); + + it("does not offer the cloud skill loader for a local packaged skill", async () => { + const { service } = createPlugins(); + service.list = () => [{ + ...plugin, + manifest: { + ...plugin.manifest, + skills: [{ + id: "review", + name: "Review", + description: "Local packaged instructions", + entry: "skills/review/SKILL.md" + }] + } + }]; + const bus = createProgressBus(); + const reference = vi.fn(); + const server = buildPluginMcpServer(service, bus, undefined, { + reference, + resolve: vi.fn(), + clear: vi.fn() + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "1" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + connections.push({ client, server }); + + expect((await client.listTools()).tools.map((tool) => tool.name)).toEqual([ + capabilityToolName(plugin.id, "run") + ]); + expect(reference).not.toHaveBeenCalled(); + }); + + it("keeps an interactive tool transport active while waiting for the user", async () => { + let release!: () => void; + const response = new Promise((resolve) => { release = resolve; }); + const service: PluginMcpService = { + list: () => [plugin], + invoke: async function* () { + yield { + type: "interaction" as const, + request: { interactionId: "card-1", type: "custom" as const, payload: {} } + }; + await response; + yield { type: "result" as const, output: { review: "done" } }; + }, + cancel: vi.fn(async () => undefined) + }; + const client = await connect(service, undefined, 5); + const progress: number[] = []; + const call = client.callTool( + { name: capabilityToolName(plugin.id, "run"), arguments: { topic: "Agent Memory" } }, + undefined, + { timeout: 1_000, onprogress: (update) => progress.push(update.progress), resetTimeoutOnProgress: true } + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(progress.length).toBeGreaterThan(1); + release(); + await expect(call).resolves.toMatchObject({ structuredContent: { review: "done" } }); + }); +}); + +it("activates metadata only, keeps private text off progress events, and rejects old clients", async () => { + const ref = { pluginId: plugin.id, skillId: "review", pluginVersion: plugin.version, revision: "r1", sha256: "a".repeat(64) }; + const secret = "PRIVATE-SKILL-MCP-SENTINEL"; + const { service, invoke } = createPlugins(); + const cloudPlugin = { ...plugin, manifest: { ...plugin.manifest, skills: [{ id: "review", name: "Review", description: "Review", entry: "skills/review/SKILL.md", cloud: { revision: ref.revision, sha256: ref.sha256 } }] } }; + service.list = () => [cloudPlugin]; + const events: unknown[] = []; + const bus = createProgressBus(); bus.on("plugin.capability_event", event => events.push(event)); + const server = buildPluginMcpServer(service, bus, undefined, { reference: () => ref, resolve: async () => secret, clear() {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "1" }); + await server.connect(serverTransport); await client.connect(clientTransport); connections.push({ client, server }); + const tools = (await client.listTools()).tools.map(tool => tool.name); + expect(tools).toContain("read_plugin_skill"); expect(tools).not.toContain("resolve_plugin_skill_context"); + const oldClient = await client.callTool({ name: "read_plugin_skill", arguments: { pluginId: plugin.id, skillId: "review" } }); + expect(oldClient.isError).toBe(true); + const activated = await client.callTool({ name: "read_plugin_skill", arguments: { pluginId: plugin.id, skillId: "review" }, _meta: { "memmy.dev/cloud-skills": "1" } }); + expect(JSON.stringify(activated)).not.toContain(secret); + expect(activated.structuredContent).toEqual({ kind: "memmy_plugin_skill_ref_v1", ref }); + const premature = await client.callTool({ name: capabilityToolName(plugin.id, "run"), arguments: { topic: "test" }, _meta: { "memmy.dev/cloud-skills": "1" } }); + expect(premature.isError).toBe(true); expect(invoke).not.toHaveBeenCalled(); + const internal = await client.callTool({ name: "resolve_plugin_skill_context", arguments: { ref }, _meta: { "memmy.dev/cloud-skills": "1" } }); + expect(internal.content).toEqual([]); expect(JSON.stringify(internal._meta)).toContain(secret); + const result = await client.callTool({ name: capabilityToolName(plugin.id, "run"), arguments: { topic: "test" }, + _meta: { "memmy.dev/cloud-skills": "1", "memmy.dev/private-skills-ready": JSON.stringify([ref]) } }); + expect(result.isError).not.toBe(true); expect(invoke).toHaveBeenCalledTimes(1); + expect(JSON.stringify(events)).not.toContain(secret); +}); 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..7e383e4a5 --- /dev/null +++ b/App/backend/src/adapters/inbound/local-api/tests/plugin-sse-recovery.test.ts @@ -0,0 +1,36 @@ +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("plugin.capability_snapshot")) { + 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"); + expect(content).toContain('"type":"plugin.capability_snapshot"'); + expect(content).toContain('"calls":[{"pluginId":"legal-labor","capabilityId":"session-start","callId":"pending-call","conversationId":"websocket:chat-1"}]'); + await reader.cancel(); + } finally { abort.abort(); await app.close(); } +}); diff --git a/App/backend/src/adapters/inbound/local-api/tests/plugins-routes.test.ts b/App/backend/src/adapters/inbound/local-api/tests/plugins-routes.test.ts new file mode 100644 index 000000000..4d55ae1d2 --- /dev/null +++ b/App/backend/src/adapters/inbound/local-api/tests/plugins-routes.test.ts @@ -0,0 +1,153 @@ +import { InstalledPluginSchema, type InstalledPlugin } from "@memmy/local-api-contracts"; +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginService } from "../../../../services/plugin-service.js"; +import { createProgressBus } from "../../../../services/progress-bus.js"; +import { registerPluginRoutes } from "../routes/plugins.js"; + +let app: FastifyInstance | undefined; + +afterEach(async () => { + await app?.close(); + app = undefined; +}); + +const plugin = InstalledPluginSchema.parse({ + id: "com.example.review", + version: "1.0.0", + manifest: { + apiVersion: "memmy/v1", + id: "com.example.review", + name: "Review", + version: "1.0.0", + runtime: { adapter: "http", config: { baseUrl: "https://plugin.example" } }, + capabilities: [{ + id: "run", + name: "Run", + description: "Run a review", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "job" + }], + permissions: [] + }, + state: "active", + approvedPermissions: [], + config: {}, + lastError: null, + createdAt: "2026-08-28T00:00:00.000Z", + updatedAt: "2026-08-28T00:00:00.000Z" +}); + +function createService(): PluginService { + return { + list: vi.fn(() => [plugin]), + get: vi.fn(() => plugin), + readUi: vi.fn(async (_id, slot) => `
${slot}
`), + openArtifact: vi.fn(async () => ({ path: new URL(import.meta.url).pathname, name: "report.txt", mediaType: "text/plain" })), + install: vi.fn(async () => plugin), + update: vi.fn(async () => plugin), + configure: vi.fn(() => plugin), + approvePermissions: vi.fn(async () => plugin), + enable: vi.fn(async () => plugin), + disable: vi.fn(async () => plugin), + uninstall: vi.fn(async () => undefined), + async *invoke() { + yield { type: "progress", current: 1, total: 1 }; + yield { type: "result", output: { ok: true } }; + }, + cancel: vi.fn(async () => undefined), + respond: vi.fn(async () => undefined), + restoreActive: vi.fn(async () => undefined), + shutdown: vi.fn(async () => undefined) + }; +} + +function createApp( + plugins: PluginService, + onEvent?: (event: unknown) => void, + refreshAgentTools?: () => Promise +): FastifyInstance { + const progressBus = createProgressBus(); + if (onEvent) progressBus.on("plugin.capability_event", onEvent); + const server = Fastify({ logger: false }); + registerPluginRoutes(server, { + plugins, + progressBus, + authenticateRuntimeToken: async () => undefined, + refreshAgentTools + }); + return server; +} + +describe("plugin routes", () => { + it("installs and manages plugins by id", async () => { + const plugins = createService(); + const refreshAgentTools = vi.fn(async () => undefined); + app = createApp(plugins, undefined, refreshAgentTools); + + expect((await app.inject({ method: "POST", url: "/api/v1/plugins/install", payload: { + pluginId: plugin.id, + version: plugin.version + } })).statusCode).toBe(201); + expect((await app.inject({ method: "GET", url: "/api/v1/plugins" })).json()).toEqual([plugin]); + expect((await app.inject({ method: "POST", url: `/api/v1/plugins/${plugin.id}/enable` })).statusCode).toBe(200); + expect((await app.inject({ method: "DELETE", url: `/api/v1/plugins/${plugin.id}` })).json()).toEqual({ ok: true }); + expect(plugins.install).toHaveBeenCalledWith(plugin.id, plugin.version); + expect(plugins.uninstall).toHaveBeenCalledWith(plugin.id); + expect(refreshAgentTools).toHaveBeenCalledTimes(3); + }); + + it("returns the installed plugin renderer", async () => { + const plugins = createService(); + app = createApp(plugins); + + const response = await app.inject({ method: "GET", url: `/api/v1/plugins/${plugin.id}/ui/renderer` }); + + expect(response.json()).toEqual({ html: "
renderer
" }); + expect(plugins.readUi).toHaveBeenCalledWith(plugin.id, "renderer"); + expect((await app.inject({ method: "GET", url: `/api/v1/plugins/${plugin.id}/ui/surface` })).json()).toEqual({ html: "
surface
" }); + }); + + it("serves Host-validated plugin artifacts for preview and download", async () => { + const plugins = createService(); + app = createApp(plugins); + + const preview = await app.inject({ method: "GET", url: `/api/v1/plugins/${plugin.id}/artifacts/00000000-0000-4000-8000-000000000000/preview` }); + const download = await app.inject({ method: "GET", url: `/api/v1/plugins/${plugin.id}/artifacts/00000000-0000-4000-8000-000000000000/download` }); + + expect(preview.statusCode).toBe(200); + expect(preview.headers["content-disposition"]).toContain("inline"); + expect(preview.headers["content-security-policy"]).toContain("sandbox"); + expect(download.headers["content-disposition"]).toContain("attachment"); + expect(plugins.openArtifact).toHaveBeenCalledTimes(2); + }); + + it("streams capability events and returns only the terminal event", async () => { + const events: unknown[] = []; + app = createApp(createService(), (event) => events.push(event)); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/plugins/${plugin.id}/capabilities/run/invoke`, + payload: { conversationId: "conversation-1", input: { topic: "memory" } } + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ event: { type: "result", output: { ok: true } } }); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ pluginId: plugin.id, capabilityId: "run", event: { type: "progress" } }); + }); + + it("rejects invalid install input before calling the service", async () => { + const plugins = createService(); + app = createApp(plugins); + + const response = await app.inject({ method: "POST", url: "/api/v1/plugins/install", payload: { + pluginId: "Not Valid" + } }); + + expect(response.statusCode).toBe(400); + expect(plugins.install).not.toHaveBeenCalled(); + }); +}); diff --git a/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts b/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts index f9e5d0aed..314dac357 100644 --- a/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts +++ b/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts @@ -16,6 +16,7 @@ import { QWEN_ASR_MODEL_ID, TokenQuotaEligibilitySchema, TokenUsageDtoSchema, + type AsrTranscriptSegment, type AuthorizeIntegrationResponse, type AccountInvitationView, type IntegrationCapabilitiesResponse, @@ -70,6 +71,14 @@ import type { const DEFAULT_TIMEOUT_MS = 5_000; const CLOUD_SOCIAL_LOGIN_TIMEOUT_MS = 20_000; const CLOUD_COMPOSIO_ROUTER_TIMEOUT_MS = 60_000; +/** fun-asr uses the same asynchronous file job for chat clips and long recordings. */ +const CLOUD_ASR_TIMEOUT_MS = 600_000; +/** + * Speaker separation runs as an upstream batch job the cloud polls on our + * behalf, so the desktop stays blocked for the whole job. Measured at roughly + * two minutes for a two-hour recording. + */ +const CLOUD_ASR_DIARIZATION_TIMEOUT_MS = 600_000; const CLOUD_COMPOSIO_UNAVAILABLE_MESSAGE = "工具连接服务暂时不可用"; const CLOUD_COMPOSIO_SERVICE_UNAVAILABLE_CODE = 60020; const CLOUD_COMPOSIO_TOOLKIT_UNSUPPORTED_CODE = 60021; @@ -374,11 +383,16 @@ export function createHttpCloudClient(options: CreateHttpCloudClientOptions = {} }, async transcribeAudio(input: CloudAsrTranscriptionInput): Promise { - const data = await requestCloudData(fetchImpl, baseUrl, timeoutMs, "/api/agentAsr/transcriptions", { + const asrTimeoutMs = Math.max( + timeoutMs, + input.diarization ? CLOUD_ASR_DIARIZATION_TIMEOUT_MS : CLOUD_ASR_TIMEOUT_MS + ); + const data = await requestCloudData(fetchImpl, baseUrl, asrTimeoutMs, "/api/agentAsr/transcriptions", { body: { audioBase64: input.audioBase64, mimeType: input.mimeType, - ...(input.durationMs === undefined ? {} : { durationMs: input.durationMs }) + ...(input.durationMs === undefined ? {} : { durationMs: input.durationMs }), + ...(input.diarization ? { diarization: true } : {}) }, lang: "zh", bearerCredential: input.uuid @@ -595,6 +609,7 @@ function toCloudAccountProfile(data: Record): CloudAccountProfi planType: readString(data.planType), hasFinishedGuide: readNullableBoolean(data.hasFinishedGuide), improvementProgramGranted: readNullableBoolean(data.improvementProgramGranted ?? data.improvement_program_granted), + entitlements: readStringArray(data.entitlements ?? data.entitlementList ?? data.entitlement_list), region: readString(data.region), registeredAt: readIsoTime(data.registeredAt, data.registerTime, data.createdAt, data.created_at, data.createTime, data.create_time), rawProfile @@ -878,13 +893,40 @@ function toOkResponse(value: unknown): OkResponse { */ function toCloudAsrTranscriptionResult(value: unknown): CloudAsrTranscriptionResult { const record = asRecord(value); + const segments = readAsrSegments(record.segments); return { text: readString(record.text) ?? "", modelId: QWEN_ASR_MODEL_ID, - provider: ASR_PROVIDER + provider: ASR_PROVIDER, + ...(segments ? { segments } : {}) }; } +/** + * Maps the Cloud diarization segments into local transcript segments. + * + * @param value the raw segments field. + * @returns the segments carrying text; returns null when diarization was not returned. + */ +function readAsrSegments(value: unknown): AsrTranscriptSegment[] | null { + if (!Array.isArray(value)) return null; + const segments = value.flatMap((entry) => { + const record = asRecord(entry); + const text = readString(record.text); + if (!text) return []; + const speakerId = readOptionalNonNegativeInteger(record.speakerId ?? record.speaker_id); + const startMs = readOptionalNonNegativeInteger(record.startMs ?? record.start_ms ?? record.beginTime); + const endMs = readOptionalNonNegativeInteger(record.endMs ?? record.end_ms ?? record.endTime); + return [{ + text, + ...(speakerId === null ? {} : { speakerId }), + ...(startMs === null ? {} : { startMs }), + ...(endMs === null ? {} : { endMs }) + }]; + }); + return segments.length ? segments : null; +} + /** * Reads the toolkit slug from a tool-config item. * @@ -1172,6 +1214,22 @@ function readTimestampNumber(value: unknown): number | null { return numericValue >= 100_000_000_000 ? numericValue : numericValue * 1000; } +/** + * Reads an optional non-negative integer such as a speaker index or a millisecond offset. + * + * Distinct from `readNonNegativeInteger`, which defaults to 0: for a speaker index and a + * segment start offset 0 is a real value, so absence must stay distinguishable from zero. + * + * @param value an unknown cloud field. + * @returns the integer; returns null when absent or not a non-negative integer. + */ +function readOptionalNonNegativeInteger(value: unknown): number | null { + const numericValue = typeof value === "number" + ? value + : typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value.trim()) : Number.NaN; + return Number.isInteger(numericValue) && numericValue >= 0 ? numericValue : null; +} + /** * Reads a string from an unknown value. * @@ -1190,6 +1248,26 @@ function readString(value: unknown): string | null { return null; } +/** + * Parses a cloud string-array field such as account entitlements. + * + * Some cloud deployments send a comma-separated string instead of an array, so both shapes are accepted. + * + * @param value the raw cloud field value. + * @returns the deduplicated non-empty entries; returns null when the field is absent or unusable. + */ +function readStringArray(value: unknown): string[] | null { + const raw = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : null; + if (!raw) { + return null; + } + + const entries = raw + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0); + return [...new Set(entries)]; +} + /** * Leniently parses a cloud boolean field. * diff --git a/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts b/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts index ffea27e3f..e9779fa31 100644 --- a/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts +++ b/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts @@ -872,6 +872,45 @@ describe("cloud client", () => { ]); }); + it("http client waits out a diarized transcription instead of applying the short default timeout", async () => { + // Speaker separation runs upstream as a batch job the cloud polls for us, + // so this single request stays open far longer than any other endpoint. + server = createServer(async (_request, response) => { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 150)); + sendJson(response, { + code: 0, + message: "ok", + data: { + text: "甲说完乙说", + modelId: "qwen-audio-3.0-asr-flash-filetrans", + provider: "aliyun", + segments: [ + { text: "甲说", speakerId: 0, startMs: 0, endMs: 500 }, + { text: "乙说", speakerId: 1, startMs: 500, endMs: 900 } + ] + } + }); + }); + await listen(server); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Mock cloud server did not bind to a port"); + } + const client = createHttpCloudClient({ baseUrl: `http://127.0.0.1:${address.port}`, timeoutMs: 20 }); + + const result = await client.transcribeAudio({ + uuid: "cloud.login.uuid", + audioBase64: "UklGRg==", + mimeType: "audio/wav", + diarization: true + }); + + expect(result.segments).toEqual([ + { text: "甲说", speakerId: 0, startMs: 0, endMs: 500 }, + { text: "乙说", speakerId: 1, startMs: 500, endMs: 900 } + ]); + }); + it("http client proxies integration capabilities/authorize/list/delete through Cloud Service with machine token", async () => { const requests: Array<{ path: string; diff --git a/App/backend/src/adapters/outbound/cloud-client/types.ts b/App/backend/src/adapters/outbound/cloud-client/types.ts index 95909dedc..59b7c9959 100644 --- a/App/backend/src/adapters/outbound/cloud-client/types.ts +++ b/App/backend/src/adapters/outbound/cloud-client/types.ts @@ -1,6 +1,7 @@ import type { AsrModelId, AsrProvider, + AsrTranscriptSegment, AuthorizeIntegrationResponse, AccountInvitationView, HealthStatus, @@ -51,6 +52,8 @@ export interface CloudAccountProfile { hasFinishedGuide: boolean | null; /** Cloud per-user flag: this account has already received the improvement-program token grant. */ improvementProgramGranted: boolean | null; + /** Cloud per-user grants, e.g. `plugin:`. Null when the cloud did not report any. */ + entitlements: string[] | null; region: string | null; registeredAt: string | null; rawProfile: Record; @@ -228,6 +231,8 @@ export interface CloudAsrTranscriptionInput { audioBase64: string; mimeType: string; durationMs?: number; + /** Requests speaker separation. Honoured only by upstream models that support it. */ + diarization?: boolean; } /** Contract for cloud asr transcription result. */ @@ -235,6 +240,8 @@ export interface CloudAsrTranscriptionResult { text: string; modelId: AsrModelId; provider: AsrProvider; + /** Present only when diarization was requested and the upstream model returned it. */ + segments?: AsrTranscriptSegment[]; } export interface CloudClient { diff --git a/App/backend/src/adapters/outbound/memmy-agent-admin-client/http-memmy-agent-admin-client.ts b/App/backend/src/adapters/outbound/memmy-agent-admin-client/http-memmy-agent-admin-client.ts index 6880fe990..4d4c02f8f 100644 --- a/App/backend/src/adapters/outbound/memmy-agent-admin-client/http-memmy-agent-admin-client.ts +++ b/App/backend/src/adapters/outbound/memmy-agent-admin-client/http-memmy-agent-admin-client.ts @@ -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; @@ -63,6 +69,14 @@ class HttpMemmyAgentAdminClient implements MemmyAgentAdminClient { this.fetchFn = options.fetchFn ?? fetch.bind(globalThis); } + async hasActiveTasks(signal?: AbortSignal): Promise { + const timeout = AbortSignal.timeout(10_000); + const response = await this.request("/api/runtime/task-activity", z.object({ busy: z.boolean() }), { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout + }); + return response.busy; + } + async getChannelDefinitions() { return this.request("/api/channels/definitions", ChannelDefinitionsResponseSchema); } @@ -71,6 +85,15 @@ class HttpMemmyAgentAdminClient implements MemmyAgentAdminClient { return this.request("/api/channels/status", ChannelConnectionsResponseSchema); } + async getSessionWorkspace(sessionKey: string): Promise { + 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" }); } @@ -140,6 +163,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; } diff --git a/App/backend/src/adapters/outbound/memmy-agent-admin-client/index.ts b/App/backend/src/adapters/outbound/memmy-agent-admin-client/index.ts index 6b52390a4..afe5bdb6a 100644 --- a/App/backend/src/adapters/outbound/memmy-agent-admin-client/index.ts +++ b/App/backend/src/adapters/outbound/memmy-agent-admin-client/index.ts @@ -6,8 +6,12 @@ import type { } from "@memmy/local-api-contracts"; export interface MemmyAgentAdminClient { + /** True for the entire Agent turn, including model thinking and interaction waits. */ + hasActiveTasks?(signal?: AbortSignal): Promise; getChannelDefinitions(): Promise; getChannelConnections(): Promise; + /** Returns the canonical workspace currently bound to a WebUI conversation. */ + getSessionWorkspace?(sessionKey: string): Promise; configureChannel(runtimeChannel: string): Promise<{ status: ChannelStatus; running: boolean }>; stopChannel(runtimeChannel: string): Promise<{ status: ChannelStatus; running: boolean }>; startWeixinLogin(): Promise<{ status: ChannelStatus; qrCodeDataUrl?: string; pollToken?: string }>; diff --git a/App/backend/src/adapters/outbound/memmy-agent-admin-client/tests/http-memmy-agent-admin-client.test.ts b/App/backend/src/adapters/outbound/memmy-agent-admin-client/tests/http-memmy-agent-admin-client.test.ts index eaafc8b02..d8cdfadb9 100644 --- a/App/backend/src/adapters/outbound/memmy-agent-admin-client/tests/http-memmy-agent-admin-client.test.ts +++ b/App/backend/src/adapters/outbound/memmy-agent-admin-client/tests/http-memmy-agent-admin-client.test.ts @@ -17,6 +17,23 @@ afterEach(async () => { }); describe("http memmy-agent admin client", () => { + it("reads authenticated task activity with a timeout and rejects malformed status", async () => { + const fetchFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const path = new URL(String(input)).pathname; + if (path === "/webui/bootstrap") return Response.json({ token: "boot-token" }); + expect(path).toBe("/api/runtime/task-activity"); + expect(init?.headers).toMatchObject({ authorization: "Bearer boot-token" }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + return Response.json({ busy: true }); + }); + const client = createHttpMemmyAgentAdminClient({ fetchFn: fetchFn as typeof fetch }); + expect(await client.hasActiveTasks!()).toBe(true); + fetchFn.mockResolvedValueOnce(Response.json({ busy: false })); + expect(await client.hasActiveTasks!()).toBe(false); + fetchFn.mockResolvedValueOnce(Response.json({})); + await expect(client.hasActiveTasks!()).rejects.toThrow(); + }); + it("applies one hard timeout to MCP reload bootstrap and admin requests", async () => { const signals: Array = []; const fetchFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { @@ -54,6 +71,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; @@ -78,6 +103,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({ @@ -88,6 +115,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" } diff --git a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts index f3c1582f6..7c6c14ac8 100644 --- a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts +++ b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts @@ -6,6 +6,7 @@ import { CompleteTurnOutputSchema, SourceTurnCompleteOutputSchema, DeleteMemoryOutputSchema, + EmbeddingInferenceOutputSchema, DeletePanelTaskOutputSchema, EnqueueImportSummariesOutputSchema, GetMemoryOutputSchema, @@ -189,6 +190,14 @@ export function createHttpMemoryClient( return request("POST", "search", SearchOutputSchema, { body: input, context }); }, + async embeddingInference(input, options) { + return request("POST", "embeddingInference", EmbeddingInferenceOutputSchema, { + body: input, + signal: options?.signal, + maxRetries: 0 + }); + }, + async addMemory(input, context) { return request("POST", "addMemory", AddMemoryOutputSchema, { body: input, context }); }, diff --git a/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts b/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts index 60276ec7e..abdf561c8 100644 --- a/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts +++ b/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts @@ -12,6 +12,7 @@ export const MEMORY_LAYER_PATHS = Object.freeze({ completeTurn: "/api/v1/turns/:turnId/complete", completeSourceTurn: "/api/v1/source-turns/complete", search: "/api/v1/memory/search", + embeddingInference: "/api/v1/models/embedding/infer", addMemory: "/api/v1/memory/add", getMemory: "/api/v1/memory/:id", deleteMemory: "/api/v1/memory/:id", diff --git a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts index cf90484e2..bc0b36c8f 100644 --- a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts +++ b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts @@ -43,6 +43,7 @@ describe("HttpMemoryClient", () => { "/api/v1/turns/:turnId/complete", "/api/v1/source-turns/complete", "/api/v1/memory/search", + "/api/v1/models/embedding/infer", "/api/v1/memory/add", "/api/v1/memory/:id", "/api/v1/memory/:id", @@ -155,6 +156,10 @@ describe("HttpMemoryClient", () => { await expect(client.completeTurn(completeTurnInput())).resolves.toMatchObject({ scheduledEvolution: false }); await expect(client.search(searchInput())).resolves.toEqual({ injectedContext: "" }); await expect(client.search({ ...searchInput(), verbose: true })).resolves.toMatchObject({ debug: { hits: [] } }); + await expect(client.embeddingInference?.({ texts: ["query", "document"], role: "document" })).resolves.toEqual({ + embeddings: [[1, 0], [0, 1]], + model: { provider: "local", model: "test-embedding", mode: "local", dimension: 2 } + }); await expect(client.addMemory(addMemoryInput())).resolves.toMatchObject({ id: "memory-1" }); await expect(client.getMemory({ memoryId: "memory-1" })).resolves.toMatchObject({ item: { id: "memory-1" } }); await expect(client.deleteMemory({ memoryId: "memory-1", source: "codex" })).resolves.toMatchObject({ status: "deleted" }); @@ -188,6 +193,7 @@ describe("HttpMemoryClient", () => { "POST /api/v1/turns/turn-1/complete", "POST /api/v1/memory/search", "POST /api/v1/memory/search", + "POST /api/v1/models/embedding/infer", "POST /api/v1/memory/add", "GET /api/v1/memory/memory-1", "DELETE /api/v1/memory/memory-1", @@ -517,6 +523,12 @@ function fixtureFor(method: string, path: string, body: unknown): unknown { if (method === "POST" && path === "/api/v1/turns/start") return startTurnOutput(body); if (method === "POST" && path === "/api/v1/turns/turn-1/complete") return completeTurnOutput(); if (method === "POST" && path === "/api/v1/memory/search") return searchOutput(body); + if (method === "POST" && path === "/api/v1/models/embedding/infer") { + return { + embeddings: [[1, 0], [0, 1]], + model: { provider: "local", model: "test-embedding", mode: "local", dimension: 2 } + }; + } if (method === "POST" && path === "/api/v1/memory/add") return addMemoryOutput(body); if (method === "GET" && path === "/api/v1/memory/memory-1") return getMemoryOutput(); if (method === "DELETE" && path === "/api/v1/memory/memory-1") return deleteMemoryOutput(); diff --git a/App/backend/src/adapters/outbound/memory-client/types.ts b/App/backend/src/adapters/outbound/memory-client/types.ts index ffebdd32d..7a2d04fc6 100644 --- a/App/backend/src/adapters/outbound/memory-client/types.ts +++ b/App/backend/src/adapters/outbound/memory-client/types.ts @@ -6,6 +6,8 @@ import type { CloseSessionOutput, DeleteMemoryInput, DeleteMemoryOutput, + EmbeddingInferenceInput, + EmbeddingInferenceOutput, DeletePanelTaskOutput, CompleteTurnInput, CompleteTurnOutput, @@ -74,6 +76,7 @@ export interface MemoryClient { completeSourceTurn(input: SourceTurnCompleteInput, context?: MemoryRequestContext): Promise; search(input: SearchInput, context?: MemoryRequestContext): Promise; + embeddingInference?(input: EmbeddingInferenceInput, options?: { signal?: AbortSignal }): Promise; addMemory(input: AddMemoryInput, context?: MemoryRequestContext): Promise; getMemory(input: { memoryId: string }, context?: MemoryRequestContext): Promise; deleteMemory(input: DeleteMemoryInput & { memoryId: string }, context?: MemoryRequestContext): Promise; diff --git a/App/backend/src/adapters/outbound/plugin-artifact/index.ts b/App/backend/src/adapters/outbound/plugin-artifact/index.ts new file mode 100644 index 000000000..7a821b127 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-artifact/index.ts @@ -0,0 +1,263 @@ +/** Verified plugin artifact installation. */ +import { createHash } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + stat +} from "node:fs/promises"; +import { createWriteStream } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Transform } from "node:stream"; +import { isDeepStrictEqual } from "node:util"; +import { PluginManifestSchema, type PluginManifest } from "@memmy/local-api-contracts"; +import JSZip, { type JSZipObject } from "jszip"; +import type { PluginRelease } from "../plugin-registry/index.js"; +import type { PluginArtifactManager } from "./types.js"; + +const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024; +const MAX_EXTRACTED_BYTES = 250 * 1024 * 1024; +const MAX_FILES = 2_000; + +export type { PluginArtifactLocation, PluginArtifactManager } from "./types.js"; + +export interface CreatePluginArtifactManagerOptions { + installRoot: string; + fetchFn?: typeof fetch; + /** Canonical desktop resource roots allowed to provide immutable local plugin packages. */ + 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 }; + 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" }); + } + + await mkdir(configuredInstallRoot, { recursive: true }); + const installRoot = await realpath(configuredInstallRoot); + const target = resolve(installRoot, release.manifest.id, digest); + assertDescendant(installRoot, target); + if (await isDirectory(target)) return { artifactHash: digest, rootPath: target }; + + const temp = await mkdtemp(join(installRoot, ".install-")); + const content = join(temp, "content"); + try { + await mkdir(content); + await extractZip(bytes, content); + await assertPackagedManifest(content, release.manifest); + const pluginRoot = dirname(target); + await mkdir(pluginRoot, { recursive: true }); + await assertCanonicalDirectory(pluginRoot); + try { + await rename(content, target); + } catch (error) { + if (!(await isDirectory(target))) throw error; + } + return { artifactHash: digest, rootPath: target }; + } finally { + await rm(temp, { recursive: true, force: true }); + } + }, + + async readTextFile(plugin, relativePath, maxBytes) { + if (!plugin.rootPath) throw new Error("Plugin has no installed artifact"); + const installRoot = await realpath(configuredInstallRoot); + const root = await realpath(plugin.rootPath); + assertDescendant(installRoot, root); + const target = resolve(root, relativePath); + assertDescendant(root, target); + const info = await lstat(target); + if (!info.isFile() || info.isSymbolicLink()) throw new Error("Plugin UI entry must be a regular file"); + if (await realpath(target) !== target) throw new Error("Plugin UI entry must not be a symbolic link"); + const content = await readFile(target); + if (info.size > maxBytes || content.byteLength > maxBytes) throw new Error("Plugin UI entry exceeded size limit"); + return content.toString("utf8"); + }, + + async remove(plugin) { + if (!plugin.rootPath) return; + const installRoot = await realpath(configuredInstallRoot); + const target = resolve(plugin.rootPath); + assertDescendant(installRoot, target); + const info = await lstat(target).catch(() => null); + if (!info) return; + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Refusing to remove an invalid plugin artifact path"); + await rm(target, { recursive: true, force: true }); + } + }; +} + +async function readTrustedLocalArtifact(path: string, trustedRoots: readonly string[]): Promise { + const requestedPath = resolve(path); + const info = await lstat(requestedPath); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error("Bundled plugin artifact must be a regular file"); + } + const canonicalPath = await realpath(requestedPath); + const allowed = await Promise.all(trustedRoots.map(async (root) => { + const rootInfo = await lstat(root); + const canonicalRoot = await realpath(root); + return rootInfo.isDirectory() + && !rootInfo.isSymbolicLink() + && isDescendant(canonicalRoot, canonicalPath); + })); + if (!allowed.some(Boolean)) throw new Error("Bundled plugin artifact is outside the trusted resource root"); + if (info.size > MAX_ARCHIVE_BYTES) throw new Error("Plugin artifact exceeded size limit"); + const bytes = await readFile(canonicalPath); + if (bytes.byteLength > MAX_ARCHIVE_BYTES) throw new Error("Plugin artifact exceeded size limit"); + return bytes; +} + +async function downloadArtifact(url: URL, fetchFn: typeof fetch, authHeaders?: Record): Promise { + const response = await fetchFn(url, { + headers: { accept: "application/zip, application/octet-stream", ...authHeaders }, + redirect: "error", + signal: AbortSignal.timeout(60_000) + }); + if (response.status === 401 || response.status === 403) { + throw Object.assign(new Error("Plugin artifact is not available for this account"), { + code: "plugin_entitlement_required" as const + }); + } + if (!response.ok) throw new Error(`Plugin artifact download failed with ${response.status}`); + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_ARCHIVE_BYTES) throw new Error("Plugin artifact exceeded size limit"); + if (!response.body) throw new Error("Plugin artifact response body is empty"); + const chunks: Uint8Array[] = []; + let total = 0; + for await (const chunk of response.body) { + total += chunk.byteLength; + if (total > MAX_ARCHIVE_BYTES) throw new Error("Plugin artifact exceeded size limit"); + chunks.push(chunk); + } + return Buffer.concat(chunks, total); +} + +async function assertPackagedManifest(root: string, expected: PluginManifest): Promise { + const manifestPath = resolve(root, "plugin.json"); + assertDescendant(root, manifestPath); + const info = await lstat(manifestPath).catch(() => null); + if (!info?.isFile() || info.isSymbolicLink() || await realpath(manifestPath) !== manifestPath) { + throw new Error("Plugin artifact must contain a regular plugin.json"); + } + if (info.size > 512 * 1024) throw new Error("Plugin artifact manifest exceeded size limit"); + const packaged = PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")) as unknown); + if (!isDeepStrictEqual(packaged, expected)) { + throw new Error("Plugin artifact manifest does not match the release descriptor"); + } +} + +async function extractZip(bytes: Buffer, destination: string): Promise { + const archive = await JSZip.loadAsync(bytes, { checkCRC32: true, createFolders: false }); + const entries = Object.values(archive.files); + if (entries.length > MAX_FILES) throw new Error("Plugin artifact contains too many files"); + let extractedBytes = 0; + for (const entry of entries) { + const relativePath = validateEntry(entry); + const target = resolve(destination, relativePath); + assertDescendant(destination, target); + if (entry.dir) { + await mkdir(target, { recursive: true }); + continue; + } + await mkdir(dirname(target), { recursive: true }); + const limiter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + extractedBytes += chunk.byteLength; + callback(extractedBytes > MAX_EXTRACTED_BYTES ? new Error("Plugin artifact expanded beyond size limit") : null, chunk); + } + }); + await pipeline(entry.nodeStream("nodebuffer"), limiter, createWriteStream(target, { flags: "wx", mode: fileMode(entry) })); + await chmod(target, fileMode(entry)); + } +} + +function validateEntry(entry: JSZipObject): string { + const rawName = entry.unsafeOriginalName ?? entry.name; + if (entry.unsafeOriginalName && entry.unsafeOriginalName !== entry.name) { + throw new Error(`Plugin artifact contains an unsafe path: ${rawName}`); + } + if (!rawName || rawName.includes("\\") || rawName.includes("\0") || isAbsolute(rawName) || /^[A-Za-z]:/.test(rawName)) { + throw new Error(`Plugin artifact contains an unsafe path: ${rawName}`); + } + const segments = rawName.replace(/\/$/, "").split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) { + throw new Error(`Plugin artifact contains an unsafe path: ${rawName}`); + } + const mode = numericMode(entry.unixPermissions); + if ((mode & 0o170000) === 0o120000) throw new Error(`Plugin artifact contains a symbolic link: ${rawName}`); + return segments.join(sep); +} + +function fileMode(entry: JSZipObject): number { + const declared = numericMode(entry.unixPermissions) & 0o777; + return declared || 0o600; +} + +function numericMode(value: number | string | null): number { + if (typeof value === "number") return value; + if (typeof value === "string") return Number.parseInt(value, 8) || 0; + return 0; +} + +function assertArtifactUrl(value: string | undefined): URL { + if (!value) throw new Error("Plugin artifact URL is missing"); + const url = new URL(value); + const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("Plugin artifact URL must use HTTPS or loopback HTTP"); + } + return url; +} + +function assertDescendant(parent: string, child: string): void { + if (!isDescendant(parent, child)) { + throw new Error("Plugin artifact path escapes the install root"); + } +} + +function isDescendant(parent: string, child: string): boolean { + const path = relative(resolve(parent), resolve(child)); + return Boolean(path) && path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path); +} + +async function isDirectory(path: string): Promise { + const info = await stat(path).catch(() => null); + if (!info?.isDirectory()) return false; + const canonical = await realpath(path); + return canonical === resolve(path); +} + +async function assertCanonicalDirectory(path: string): Promise { + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink() || await realpath(path) !== resolve(path)) { + throw new Error("Plugin artifact directory must not contain symbolic links"); + } +} 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 new file mode 100644 index 000000000..f436fa8f9 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-artifact/tests/plugin-artifact.test.ts @@ -0,0 +1,141 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import JSZip from "jszip"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPluginArtifactManager } from "../index.js"; + +let root: string | undefined; + +afterEach(() => { + if (root) rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +async function release(fileName = "runtime/plugin.sh") { + const manifest = { + apiVersion: "memmy/v1" as const, + id: "com.example.command", + name: "Command plugin", + version: "1.0.0", + runtime: { adapter: "command" as const, config: { command: "runtime/plugin.sh" } }, + capabilities: [{ + id: "run", + name: "Run", + description: "Run command", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "request" as const + }], + permissions: [] + }; + const zip = new JSZip(); + zip.file(fileName, "#!/bin/sh\nprintf '{\"ok\":true}'\n", { unixPermissions: 0o100755 }); + zip.file("plugin.json", JSON.stringify(manifest)); + const bytes = await zip.generateAsync({ type: "nodebuffer", platform: "UNIX" }); + return { + bytes, + release: { + manifest, + artifact: { + url: "https://registry.example/plugin.zip", + sha256: createHash("sha256").update(bytes).digest("hex") + } + } + }; +} + +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(); + const manager = createPluginArtifactManager({ + installRoot: root, + fetchFn: vi.fn(async () => new Response(input.bytes)) as typeof fetch + }); + const installed = await manager.install(input.release); + expect(installed.artifactHash).toBe(input.release.artifact.sha256); + expect(readFileSync(join(installed.rootPath!, "runtime/plugin.sh"), "utf8")).toContain("#!/bin/sh"); + await expect(manager.readTextFile(installed, "runtime/plugin.sh", 1_024)).resolves.toContain("#!/bin/sh"); + await expect(manager.readTextFile(installed, "../outside.html", 1_024)).rejects.toThrow(/escapes/); + await manager.remove(installed); + expect(() => readFileSync(join(installed.rootPath!, "runtime/plugin.sh"))).toThrow(); + }); + + it("rejects a digest mismatch", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-artifact-")); + const input = await release(); + input.release.artifact.sha256 = "0".repeat(64); + const manager = createPluginArtifactManager({ + installRoot: root, + fetchFn: vi.fn(async () => new Response(input.bytes)) as typeof fetch + }); + await expect(manager.install(input.release)).rejects.toThrow(/SHA-256 mismatch/); + }); + + it("rejects path traversal entries", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-artifact-")); + const input = await release("../outside.txt"); + const manager = createPluginArtifactManager({ + installRoot: root, + fetchFn: vi.fn(async () => new Response(input.bytes)) as typeof fetch + }); + await expect(manager.install(input.release)).rejects.toThrow(/unsafe path/); + }); + + it("installs only regular local archives inside a trusted bundled root", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-artifact-")); + const bundleRoot = join(root, "bundled"); + const installRoot = join(root, "installed"); + mkdirSync(bundleRoot); + const input = await release(); + const archivePath = join(bundleRoot, "plugin.mpp.zip"); + writeFileSync(archivePath, input.bytes); + const manager = createPluginArtifactManager({ + installRoot, + trustedLocalRoots: [bundleRoot] + }); + + const installed = await manager.install({ + ...input.release, + artifact: { localPath: archivePath, sha256: input.release.artifact.sha256 } + }); + expect(readFileSync(join(installed.rootPath!, "plugin.json"), "utf8")).toContain("com.example.command"); + + const outsidePath = join(root, "outside.mpp.zip"); + writeFileSync(outsidePath, input.bytes); + await expect(manager.install({ + ...input.release, + artifact: { localPath: outsidePath, sha256: input.release.artifact.sha256 } + })).rejects.toThrow(/outside the trusted/); + + const symlinkPath = join(bundleRoot, "linked.mpp.zip"); + symlinkSync(outsidePath, symlinkPath); + await expect(manager.install({ + ...input.release, + artifact: { localPath: symlinkPath, sha256: input.release.artifact.sha256 } + })).rejects.toThrow(/regular file/); + }); +}); diff --git a/App/backend/src/adapters/outbound/plugin-artifact/types.ts b/App/backend/src/adapters/outbound/plugin-artifact/types.ts new file mode 100644 index 000000000..30e56132b --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-artifact/types.ts @@ -0,0 +1,12 @@ +import type { PluginRelease } from "../plugin-registry/index.js"; + +export interface PluginArtifactLocation { + artifactHash: string | null; + rootPath: string | null; +} + +export interface PluginArtifactManager { + install(release: PluginRelease): Promise; + readTextFile(plugin: PluginArtifactLocation, relativePath: string, maxBytes: number): Promise; + remove(plugin: PluginArtifactLocation): Promise; +} diff --git a/App/backend/src/adapters/outbound/plugin-registry/bundled-plugin-registry.ts b/App/backend/src/adapters/outbound/plugin-registry/bundled-plugin-registry.ts new file mode 100644 index 000000000..214393f63 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-registry/bundled-plugin-registry.ts @@ -0,0 +1,184 @@ +/** Loads immutable first-party plugin releases from a trusted desktop resource directory. */ +import { createHash } from "node:crypto"; +import { lstat, readFile, readdir, realpath } from "node:fs/promises"; +import { basename, isAbsolute, relative, resolve, sep } from "node:path"; +import { PluginManifestSchema, type PluginManifest } from "@memmy/local-api-contracts"; +import type { PluginRegistry, PluginRelease } from "./index.js"; + +const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024; +const BUNDLED_PLUGIN_STATE_FILE = "bundled-plugins-state.json"; +const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/; + +export interface BundledPluginRelease { + id: string; + version: string; +} + +export interface BundledPluginCatalog { + registry: PluginRegistry; + releases: BundledPluginRelease[]; + /** Plugin IDs owned by the bundle policy, including intentionally omitted releases. */ + managedPluginIds: string[]; + trustedArtifactRoot: string; +} + +interface ReleaseDescriptor { + manifest: PluginManifest; + artifact: { + file: string; + sha256: string; + }; +} + +/** Reads and verifies every `*.release.json` descriptor in a bundled resource directory. */ +export async function loadBundledPluginCatalog(directory: string): Promise { + const requestedRoot = resolve(directory); + const rootInfo = await lstat(requestedRoot); + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) { + throw invalidBundle("Bundled plugin root must be a regular directory"); + } + const trustedArtifactRoot = await realpath(requestedRoot); + + const resourceNames = await readdir(trustedArtifactRoot); + const descriptorNames = resourceNames + .filter((name) => name.endsWith(".release.json")) + .sort(); + const declaredManagedPluginIds = resourceNames.includes(BUNDLED_PLUGIN_STATE_FILE) + ? await readManagedPluginIds(trustedArtifactRoot) + : null; + + const releases = new Map(); + for (const descriptorName of descriptorNames) { + if (basename(descriptorName) !== descriptorName) { + throw invalidBundle(`Invalid bundled plugin descriptor path: ${descriptorName}`); + } + const descriptorPath = resolve(trustedArtifactRoot, descriptorName); + await assertRegularChild(trustedArtifactRoot, descriptorPath, "descriptor"); + const descriptor = parseDescriptor(JSON.parse(await readFile(descriptorPath, "utf8")) as unknown); + if (releases.has(descriptor.manifest.id)) { + throw invalidBundle(`Duplicate bundled plugin id: ${descriptor.manifest.id}`); + } + + const artifactPath = resolve(trustedArtifactRoot, descriptor.artifact.file); + const artifactInfo = await assertRegularChild(trustedArtifactRoot, artifactPath, "artifact"); + if (artifactInfo.size > MAX_ARCHIVE_BYTES) { + throw invalidBundle(`Bundled plugin artifact exceeds size limit: ${descriptor.manifest.id}`); + } + const bytes = await readFile(artifactPath); + const digest = createHash("sha256").update(bytes).digest("hex"); + if (digest !== descriptor.artifact.sha256) { + throw invalidBundle(`Bundled plugin SHA-256 mismatch: ${descriptor.manifest.id}`); + } + + releases.set(descriptor.manifest.id, { + manifest: descriptor.manifest, + artifact: { + localPath: artifactPath, + sha256: digest + } + }); + } + + const bundledReleases = [...releases.values()].map((release) => ({ + id: release.manifest.id, + version: release.manifest.version + })); + if ( + declaredManagedPluginIds + && bundledReleases.some((release) => !declaredManagedPluginIds.includes(release.id)) + ) { + throw invalidBundle("Bundled plugin release is missing from managed plugin state"); + } + return { + trustedArtifactRoot, + releases: bundledReleases, + managedPluginIds: declaredManagedPluginIds ?? bundledReleases.map((release) => release.id), + registry: { + async list() { return [...releases.values()].map((release) => structuredClone(release.manifest)); }, + async resolve(pluginId, version) { + const release = releases.get(pluginId); + if (!release || (version && release.manifest.version !== version)) { + throw Object.assign( + new Error(`Plugin release not found: ${pluginId}${version ? `@${version}` : ""}`), + { code: "not_found" as const } + ); + } + return structuredClone(release); + } + } + }; +} + +async function readManagedPluginIds(root: string): Promise { + const path = resolve(root, BUNDLED_PLUGIN_STATE_FILE); + await assertRegularChild(root, path, "state"); + let value: unknown; + try { + value = JSON.parse(await readFile(path, "utf8")); + } catch { + throw invalidBundle("Invalid bundled plugin state"); + } + if (!value || typeof value !== "object") throw invalidBundle("Invalid bundled plugin state"); + const candidate = value as { schemaVersion?: unknown; managedPluginIds?: unknown }; + if ( + candidate.schemaVersion !== 1 + || !Array.isArray(candidate.managedPluginIds) + || candidate.managedPluginIds.some((id) => typeof id !== "string" || !PLUGIN_ID_PATTERN.test(id)) + || new Set(candidate.managedPluginIds).size !== candidate.managedPluginIds.length + ) { + throw invalidBundle("Invalid bundled plugin state"); + } + return [...candidate.managedPluginIds].sort(); +} + +function parseDescriptor(value: unknown): ReleaseDescriptor { + if (!value || typeof value !== "object") throw invalidBundle("Invalid bundled plugin release descriptor"); + const candidate = value as { manifest?: unknown; artifact?: unknown }; + const manifest = PluginManifestSchema.parse(candidate.manifest); + if (!candidate.artifact || typeof candidate.artifact !== "object") { + throw invalidBundle(`Bundled plugin artifact is missing: ${manifest.id}`); + } + const artifact = candidate.artifact as { file?: unknown; sha256?: unknown }; + if ( + typeof artifact.file !== "string" + || !artifact.file + || basename(artifact.file) !== artifact.file + || isAbsolute(artifact.file) + ) { + throw invalidBundle(`Invalid bundled plugin artifact filename: ${manifest.id}`); + } + if (typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) { + throw invalidBundle(`Invalid bundled plugin SHA-256: ${manifest.id}`); + } + return { + manifest, + artifact: { + file: artifact.file, + sha256: artifact.sha256.toLowerCase() + } + }; +} + +async function assertRegularChild( + root: string, + path: string, + kind: string +): Promise>> { + assertDescendant(root, path); + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || await realpath(path) !== path) { + throw invalidBundle(`Bundled plugin ${kind} must be a regular file`); + } + return info; +} + +function assertDescendant(parent: string, child: string): void { + const path = relative(resolve(parent), resolve(child)); + if (!path || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) { + throw invalidBundle("Bundled plugin path escapes the resource root"); + } +} + +function invalidBundle(message: string): Error { + return Object.assign(new Error(message), { code: "plugin_invalid" as const }); +} 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 new file mode 100644 index 000000000..3409d2bb3 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-registry/http-plugin-registry.ts @@ -0,0 +1,96 @@ +/** HTTPS-backed trusted plugin registry. */ +import { PluginManifestSchema } from "@memmy/local-api-contracts"; +import { z } from "zod"; +import type { PluginRegistry } from "./index.js"; + +const MAX_REGISTRY_RESPONSE_BYTES = 1024 * 1024; +const PluginReleaseSchema = z.object({ + manifest: PluginManifestSchema, + artifact: z.object({ + // Local registries return an origin-relative authenticated download path. + url: z.string().url().or(z.string().regex(/^\/(?!\/)/)), + sha256: z.string().regex(/^[a-f0-9]{64}$/i) + }).optional() +}); + +export interface CreateHttpPluginRegistryOptions { + baseUrl: string; + fetchFn?: typeof fetch; + /** Supplies account credentials so the registry can gate entitlement-restricted releases. */ + authHeaders?: () => Record; +} + +export function createHttpPluginRegistry(options: CreateHttpPluginRegistryOptions): PluginRegistry { + const baseUrl = new URL(options.baseUrl); + assertAllowedProtocol(baseUrl); + const fetchFn = options.fetchFn ?? fetch.bind(globalThis); + + return { + async list() { + const response = await fetchFn(new URL("/api/v1/plugins", baseUrl), { + headers: { accept: "application/json", ...options.authHeaders?.() }, + redirect: "error", signal: AbortSignal.timeout(15_000) + }); + if (!response.ok) throw new Error(`Plugin catalog request failed with ${response.status}`); + return z.array(PluginManifestSchema).parse(JSON.parse(await readLimitedText(response))); + }, + async resolve(pluginId, version) { + const url = new URL(`/api/v1/plugins/${encodeURIComponent(pluginId)}`, baseUrl); + if (version) url.searchParams.set("version", version); + const response = await fetchFn(url, { + headers: { accept: "application/json", ...options.authHeaders?.() }, + redirect: "error", + signal: AbortSignal.timeout(15_000) + }); + if (response.status === 401 || response.status === 403) { + throw Object.assign(new Error(`Plugin release is not available for this account: ${pluginId}`), { + code: "plugin_entitlement_required" as const + }); + } + if (response.status === 404) { + throw Object.assign(new Error(`Plugin release not found: ${pluginId}`), { code: "not_found" as const }); + } + 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" }); + } + if (release.artifact) { + const artifactUrl = new URL(release.artifact.url, baseUrl); + assertAllowedProtocol(artifactUrl); + release.artifact.url = artifactUrl.toString(); + } + return release; + } + }; +} + +function assertAllowedProtocol(url: URL): void { + const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("Plugin registry must use HTTPS or loopback HTTP"); + } +} + +async function readLimitedText(response: Response): Promise { + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > MAX_REGISTRY_RESPONSE_BYTES) { + throw new Error("Plugin registry response exceeded size limit"); + } + if (!response.body) return ""; + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + for await (const chunk of response.body) { + bytes += chunk.byteLength; + if (bytes > MAX_REGISTRY_RESPONSE_BYTES) throw new Error("Plugin registry response exceeded size limit"); + text += decoder.decode(chunk, { stream: true }); + } + return text + decoder.decode(); +} diff --git a/App/backend/src/adapters/outbound/plugin-registry/index.ts b/App/backend/src/adapters/outbound/plugin-registry/index.ts new file mode 100644 index 000000000..0a48ae23b --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-registry/index.ts @@ -0,0 +1,65 @@ +/** Trusted plugin registry boundary. */ +import type { PluginManifest } from "@memmy/local-api-contracts"; + +export type PluginArtifactDescriptor = { + sha256: string; +} & ( + | { url: string; localPath?: never } + | { localPath: string; url?: never } +); + +export interface PluginRelease { + manifest: PluginManifest; + artifact?: PluginArtifactDescriptor; +} + +export interface PluginRegistry { + list?(): Promise; + resolve(pluginId: string, version?: string): Promise; +} + +export { createHttpPluginRegistry, type CreateHttpPluginRegistryOptions } from "./http-plugin-registry.js"; +export { + loadBundledPluginCatalog, + type BundledPluginCatalog, + type BundledPluginRelease +} from "./bundled-plugin-registry.js"; + +/** Resolves bundled releases first while retaining an optional development registry fallback. */ +export function createCompositePluginRegistry( + primary: PluginRegistry, + fallback?: PluginRegistry +): PluginRegistry { + return { + async list() { + const entries = [...await primary.list?.() ?? [], ...await fallback?.list?.() ?? []]; + return [...new Map(entries.map((manifest) => [manifest.id, manifest])).values()]; + }, + async resolve(pluginId, version) { + try { + return await primary.resolve(pluginId, version); + } catch (error) { + if (!fallback || (error as { code?: unknown })?.code !== "not_found") throw error; + return fallback.resolve(pluginId, version); + } + } + }; +} + +export function createInMemoryPluginRegistry(releases: PluginRelease[]): PluginRegistry { + return { + async list() { return releases.map((release) => structuredClone(release.manifest)); }, + async resolve(pluginId, version) { + const matches = releases.filter((release) => + release.manifest.id === pluginId && (!version || release.manifest.version === version) + ); + const release = matches.at(-1); + if (!release) { + throw Object.assign(new Error(`Plugin release not found: ${pluginId}${version ? `@${version}` : ""}`), { + code: "not_found" as const + }); + } + return structuredClone(release); + } + }; +} diff --git a/App/backend/src/adapters/outbound/plugin-registry/tests/bundled-plugin-registry.test.ts b/App/backend/src/adapters/outbound/plugin-registry/tests/bundled-plugin-registry.test.ts new file mode 100644 index 000000000..a54ff612c --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-registry/tests/bundled-plugin-registry.test.ts @@ -0,0 +1,76 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadBundledPluginCatalog } from "../bundled-plugin-registry.js"; + +let root: string | undefined; + +afterEach(() => { + if (root) rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +describe("loadBundledPluginCatalog", () => { + it("accepts an empty catalog for plugin-free development", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-empty-bundled-registry-")); + const catalog = await loadBundledPluginCatalog(root); + + expect(catalog.releases).toEqual([]); + expect(catalog.managedPluginIds).toEqual([]); + await expect(catalog.registry.resolve("literature-review")) + .rejects.toMatchObject({ code: "not_found" }); + }); + + it("retains managed IDs when releases are intentionally omitted", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-omitted-bundled-registry-")); + writeFileSync(join(root, "bundled-plugins-state.json"), JSON.stringify({ + schemaVersion: 1, + managedPluginIds: ["literature-review"] + })); + + const catalog = await loadBundledPluginCatalog(root); + + expect(catalog.releases).toEqual([]); + expect(catalog.managedPluginIds).toEqual(["literature-review"]); + }); + + it("pins local artifacts to the descriptor SHA-256", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-bundled-registry-")); + const bundleRoot = join(root, "bundled"); + mkdirSync(bundleRoot); + const artifact = Buffer.from("immutable archive"); + const sha256 = createHash("sha256").update(artifact).digest("hex"); + writeFileSync(join(bundleRoot, "review.mpp.zip"), artifact); + writeFileSync(join(bundleRoot, "review.release.json"), JSON.stringify({ + manifest: { + apiVersion: "memmy/v1", + id: "literature-review", + name: "Literature Review", + version: "0.5.17", + runtime: { adapter: "command", config: { command: "dist/runtime.js" } }, + capabilities: [{ + id: "review_create_task", + name: "Create task", + description: "Create a literature-review task.", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "request" + }], + permissions: [] + }, + artifact: { file: "review.mpp.zip", sha256 } + })); + + const catalog = await loadBundledPluginCatalog(bundleRoot); + expect(catalog.managedPluginIds).toEqual(["literature-review"]); + await expect(catalog.registry.resolve("literature-review", "0.5.17")).resolves.toMatchObject({ + manifest: { id: "literature-review", version: "0.5.17" }, + artifact: { localPath: join(catalog.trustedArtifactRoot, "review.mpp.zip"), sha256 } + }); + + writeFileSync(join(bundleRoot, "review.mpp.zip"), "tampered"); + await expect(loadBundledPluginCatalog(bundleRoot)).rejects.toThrow(/SHA-256 mismatch/); + }); +}); 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 new file mode 100644 index 000000000..cea69a1de --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-registry/tests/http-plugin-registry.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHttpPluginRegistry } from "../http-plugin-registry.js"; + +const release = { + manifest: { + apiVersion: "memmy/v1", + id: "com.example.review", + name: "Review", + version: "1.0.0", + runtime: { adapter: "http", config: { baseUrl: "https://plugin.example" } }, + capabilities: [{ + id: "run", + name: "Run", + description: "Run review", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "request" + }], + permissions: [{ type: "network", hosts: ["plugin.example"] }] + }, + artifact: { + url: "https://registry.example/artifacts/review.zip", + sha256: "a".repeat(64) + } +}; + +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" } + })); + const registry = createHttpPluginRegistry({ baseUrl: "https://registry.example", fetchFn: fetchFn as typeof fetch }); + await expect(registry.resolve(release.manifest.id, "1.0.0")).resolves.toEqual(release); + expect(String(fetchFn.mock.calls[0]?.[0])).toBe( + "https://registry.example/api/v1/plugins/com.example.review?version=1.0.0" + ); + }); + + it("resolves local authenticated download paths against the registry 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: "http://127.0.0.1:8101", fetchFn: fetchFn as typeof fetch, + authHeaders: () => ({ authorization: "Bearer local-test-token" }) + }); + const resolved = await registry.resolve(release.manifest.id, "1.0.0"); + expect(resolved.artifact?.url).toBe("http://127.0.0.1:8101/api/v1/plugins/com.example.review/artifact?version=1.0.0"); + expect(fetchFn).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ + headers: expect.objectContaining({ authorization: "Bearer local-test-token" }) + })); + }); + + it("rejects insecure artifact origins and ambiguous relative paths", async () => { + for (const artifactUrl of ["http://outside.example/review.zip", "//outside.example/review.zip", "../review.zip"]) { + const registry = createHttpPluginRegistry({ baseUrl: "http://127.0.0.1:8101", fetchFn: (async () => + new Response(JSON.stringify({ ...release, artifact: { ...release.artifact, url: artifactUrl } }))) as typeof fetch }); + await expect(registry.resolve(release.manifest.id, "1.0.0")).rejects.toThrow(); + } + }); + + it("rejects a mismatched manifest", async () => { + const fetchFn = vi.fn(async () => new Response(JSON.stringify({ + ...release, + manifest: { ...release.manifest, id: "com.example.other" } + }))); + const registry = createHttpPluginRegistry({ baseUrl: "https://registry.example", fetchFn: fetchFn as typeof fetch }); + await expect(registry.resolve(release.manifest.id)).rejects.toThrow(/different release/); + }); + + it("keeps the catalog version when the default changes and rejects a server substitution", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname === "/api/v1/plugins") return new Response(JSON.stringify([release.manifest])); + const selected = url.searchParams.get("version") ?? "2.0.0"; + return new Response(JSON.stringify({ ...release, manifest: { ...release.manifest, version: selected } })); + }); + const registry = createHttpPluginRegistry({ baseUrl: "https://registry.example", fetchFn: fetchFn as typeof fetch }); + const [selected] = await registry.list!(); + expect((await registry.resolve(selected!.id, selected!.version)).manifest.version).toBe("1.0.0"); + fetchFn.mockImplementation(async () => new Response(JSON.stringify({ + ...release, manifest: { ...release.manifest, version: "2.0.0" } + }))); + await expect(registry.resolve(selected!.id, selected!.version)).rejects.toThrow(/different release/); + }); + + it("rejects an insecure non-loopback registry", () => { + expect(() => createHttpPluginRegistry({ baseUrl: "http://registry.example" })).toThrow(/HTTPS/); + }); +}); diff --git a/App/backend/src/adapters/outbound/plugin-runtime/command-adapter.ts b/App/backend/src/adapters/outbound/plugin-runtime/command-adapter.ts new file mode 100644 index 000000000..0994684ee --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/command-adapter.ts @@ -0,0 +1,864 @@ +/** Sandboxed local command plugin runtime adapter. */ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { constants as fsConstants } from "node:fs"; +import { access, lstat, mkdir, realpath } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, relative, resolve, sep, win32 } from "node:path"; +import { createInterface } from "node:readline"; +import { + CapabilityEventSchema, + PluginHostServiceRequestSchema, + type CapabilityCall, + type CapabilityEvent, + type PluginHostServiceRequest, + type PluginHostServiceResponse, + type PluginRuntime +} from "@memmy/local-api-contracts"; +import { z } from "zod"; +import { callTimeoutMs, isCapabilityEvent } from "./shared.js"; +import type { PluginAdapter, PluginHostServiceInvoker, PluginRuntimeContext, PluginSession } from "./types.js"; +import { createOfficeRuntimeInstaller, type OfficeRuntimeInstaller } from "./office-installer.js"; +import { resolvePluginRegistryBaseUrl } from "../../../config/service-urls.js"; + +const MAX_OUTPUT_BYTES = 50 * 1024 * 1024; +const MAX_STDERR_BYTES = 64 * 1024; +const MAX_HOST_SERVICE_REQUESTS_PER_CALL = 16; +const CommandRuntimeDependencySchema = z.enum(["texlive", "office"]); + +const CommandRuntimeConfigSchema = z.object({ + command: z.string().trim().min(1), + interpreter: z.enum(["direct", "node"]).default("direct"), + args: z.array(z.string()).default([]), + cwd: z.string().default("."), + inputMode: z.enum(["stdin-json", "argument-json"]).default("stdin-json"), + outputMode: z.enum(["json", "ndjson"]).default("json"), + interactive: z.boolean().default(false), + runtimeDependencies: z.array(CommandRuntimeDependencySchema).max(8).default([]), + env: z.record(z.string(), z.string()).default({}), + secretEnv: z.record(z.string(), z.string().min(1)).default({}), + timeoutMs: z.number().int().positive().max(3_600_000).default(300_000), + maxOutputBytes: z.number().int().positive().max(MAX_OUTPUT_BYTES).default(10 * 1024 * 1024), + /** + * Requests an unsandboxed child process. Only honoured for plugins whose + * manifest declares a `requiredEntitlement` the account actually holds, so a + * third-party plugin cannot exempt itself by editing its own runtime config. + */ + sandbox: z.enum(["required", "none"]).default("required") +}); +export type CommandRuntimeConfig = z.infer; + +export interface SandboxLaunch { + windowsAppContainer?: boolean; + command: string; + args: string[]; + cwd: string; +} + +interface CommandPluginSession extends PluginSession { + launch: SandboxLaunch; + config: CommandRuntimeConfig; + pluginConfig: Readonly>; + env: Record; + children: Map; + hostServiceControllers: Map>; + approvedHostServices: Set; +} + +export interface CreateCommandPluginAdapterOptions { + platform?: NodeJS.Platform; + spawnFn?: typeof spawn; + buildLaunch?: ( + context: PluginRuntimeContext, + config: CommandRuntimeConfig, + networkEnabled: boolean, + runtimeDependencies: ResolvedCommandRuntimeDependencies + ) => Promise; + /** Exact DNS hosts local command plugins may request in their manifest. */ + allowedNetworkHosts?: readonly string[]; + /** Host-owned upload roots made readable only to plugins approved for the file-input host service. */ + fileInputRoots?: readonly string[]; + /** Host-owned parent directory containing one writable data directory per plugin. */ + pluginDataRoot?: string; + /** Host-created dependency installer; null disables downloading (offline deployments/tests). */ + officeRuntimeInstaller?: OfficeRuntimeInstaller | null; + /** Host-owned services callable over the private command runtime protocol. */ + hostServices?: PluginHostServiceInvoker; + /** Resolves whether the signed-in account holds a manifest-declared entitlement. */ + isEntitlementGranted?: (entitlement: string) => boolean; +} + +/** + * An unsandboxed child process is only granted to plugins the Host distributes + * itself. A manifest-declared entitlement is that signal: entitlements are + * issued by the cloud, so a third-party plugin can declare one but no account + * will ever hold it, and the sandbox stays on. + */ +function sandboxExempt( + config: CommandRuntimeConfig, + context: PluginRuntimeContext, + isEntitlementGranted: (entitlement: string) => boolean +): boolean { + if (config.sandbox !== "none") return false; + const entitlement = context.plugin.manifest.requiredEntitlement; + return Boolean(entitlement && isEntitlementGranted(entitlement)); +} + +export function createCommandPluginAdapter(options: CreateCommandPluginAdapterOptions = {}): PluginAdapter { + const platform = options.platform ?? process.platform; + const spawnFn = options.spawnFn ?? spawn; + const isEntitlementGranted = options.isEntitlementGranted ?? (() => false); + const buildLaunch = options.buildLaunch ?? ((context, config, networkEnabled, runtimeDependencies) => ( + sandboxExempt(config, context, isEntitlementGranted) + ? buildDirectLaunch(context, config) + : buildPluginSandboxLaunch( + context, + config, + platform, + networkEnabled, + options.fileInputRoots ?? [], + options.pluginDataRoot, + runtimeDependencies.readRoots, + runtimeDependencies.executableRoots + ) + )); + const allowedNetworkHosts = new Set((options.allowedNetworkHosts ?? []).map((host) => host.trim().toLowerCase()).filter(Boolean)); + + return { + id: "command", + + validate(runtime, rootPath) { + validateCommandConfig(runtime, rootPath, platform); + }, + + async activate(context) { + const config = validateCommandConfig(context.plugin.manifest.runtime, context.rootPath, platform); + if (config.sandbox === "none" && !sandboxExempt(config, context, isEntitlementGranted) && !isSandboxablePlatform(platform)) { + throw new Error(`Sandboxed command plugins are unsupported on ${platform}`); + } + const requestedNetworkHosts = context.plugin.manifest.permissions + .filter((permission) => permission.type === "network") + .flatMap((permission) => permission.hosts); + const deniedHost = requestedNetworkHosts.find((host) => !allowedNetworkHosts.has(host)); + if (deniedHost) throw Object.assign(new Error(`Command plugin network host is not in the host allowlist: ${deniedHost}`), { + code: "plugin_permission_denied" + }); + const runtimeDependencies = await resolveCommandRuntimeDependencies(config.runtimeDependencies, platform, + options.officeRuntimeInstaller === null ? undefined : options.officeRuntimeInstaller ?? defaultOfficeInstaller(options.pluginDataRoot)); + const env = resolvePluginEnvironment( + config.env, + config.secretEnv, + context.secrets, + runtimeDependencies.pathEntries, + runtimeDependencies.environment + ); + // In packaged and Electron-hosted development builds, process.execPath is + // the Electron executable. `interpreter: "node"` intentionally reuses + // that trusted Host runtime, so the child must opt into Electron's Node + // compatibility mode. The variable is harmless when process.execPath is + // already a standalone Node binary. + if (config.interpreter === "node") env.ELECTRON_RUN_AS_NODE = "1"; + if (process.env.MEMMY_PLUGIN_SANDBOX_TRACE === "1") env.MEMMY_OFFICE_TRACE = "1"; + const pluginDataPath = await resolvePluginDataPath(context, options.pluginDataRoot); + if (pluginDataPath) { + env.MEMMY_PLUGIN_DATA_DIR = pluginDataPath; + if (platform === "win32") { + const temporary = resolve(pluginDataPath, "tmp"); await mkdir(temporary, { recursive: true }); + env.TEMP = temporary; env.TMP = temporary; + } + } + if (platform === "win32") env.MEMMY_PLUGIN_INPUT_ROOTS = JSON.stringify(approvedHostService(context, "file-input") + ? await Promise.all((options.fileInputRoots ?? []).map(root => realpath(root))) : []); + return { + pluginId: context.plugin.id, + launch: await buildLaunch(context, config, requestedNetworkHosts.length > 0, runtimeDependencies), + config, + pluginConfig: context.config, + env, + children: new Map(), + hostServiceControllers: new Map(), + approvedHostServices: new Set(context.plugin.approvedPermissions + .filter((permission) => permission.type === "host-service") + .flatMap((permission) => permission.services)) + } satisfies CommandPluginSession; + }, + + async *invoke(rawSession, call) { + const session = asCommandSession(rawSession); + const request = JSON.stringify({ + callId: call.callId, + pluginId: call.pluginId, + capabilityId: call.capabilityId, + conversationId: call.conversationId, + input: call.input, + deadline: call.deadline, + config: session.pluginConfig, + hostServiceBudget: { maxRequests: MAX_HOST_SERVICE_REQUESTS_PER_CALL } + }); + const args = session.launch.args.map((arg) => applyPlaceholders(arg, call)); + if (session.config.inputMode === "argument-json") args.push(request); + const child = spawnFn(session.launch.command, args, { + cwd: session.launch.cwd, + env: session.launch.windowsAppContainer ? windowsLauncherEnvironment(session.env) : session.env, + shell: false, + stdio: ["pipe", "pipe", "pipe"] + }); + session.children.set(call.callId, child); + // A write callback does not consume the stream's separate error event. + // Cancellation or an early plugin exit can close stdin during a reply. + let inputError: Error | undefined; + child.stdin.on("error", (error: Error) => { + inputError = error; + terminate(child); + }); + child.once("close", () => abortHostServices(session, call.callId)); + if (session.config.inputMode === "stdin-json") child.stdin.write(`${request}\n`); + if (!session.config.interactive) child.stdin.end(); + + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + if (stderr.length < MAX_STDERR_BYTES) { + const diagnostic = chunk.toString("utf8", 0, MAX_STDERR_BYTES - stderr.length); + stderr += diagnostic; + if (process.env.MEMMY_PLUGIN_SANDBOX_TRACE === "1") console.info(`Plugin sandbox stderr: ${JSON.stringify(diagnostic)}`); + } + }); + const exit = processExit(child); + let timedOut = false; + let timer: ReturnType | undefined = setTimeout(() => { + timedOut = true; + terminate(child); + }, callTimeoutMs(session.config.timeoutMs, call.deadline)); + try { + let terminal: CapabilityEvent | null = null; + let jsonOutput: Buffer | null = null; + if (session.config.outputMode === "ndjson") { + let bytes = 0; + let hostServiceRequests = 0; + // Host-service requests are dispatched as they arrive rather than one + // at a time. A plugin whose capability fans out — the legal + // diagnosis judges sections in parallel — would otherwise queue + // behind itself and get nothing from the concurrency it asked for. + // Each is bounded by the per-call request cap below, and the plugin + // decides how many to have in flight. + const inFlight = new Set>(); + const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); + for await (const line of lines) { + bytes += Buffer.byteLength(line) + 1; + if (bytes > session.config.maxOutputBytes) { + terminate(child); + throw new Error("Plugin command output exceeded size limit"); + } + if (line.trim()) { + const raw = JSON.parse(line); + const hostRequest = PluginHostServiceRequestSchema.safeParse(raw); + if (hostRequest.success) { + // Counted synchronously, so concurrent requests cannot share a + // slot and slip past the cap. + hostServiceRequests += 1; + const dispatched = respondToHostServiceRequest(child, session, call, hostRequest.data, options.hostServices, hostServiceRequests) + .catch(() => undefined) + .finally(() => inFlight.delete(dispatched)); + inFlight.add(dispatched); + continue; + } + const event = CapabilityEventSchema.parse(raw); + if (event.type === "result" || event.type === "error") { + if (terminal) throw new Error("Plugin command emitted multiple terminal events"); + terminal = event; + } else { + // Once an interactive card is visible, user think time must not + // consume the command execution budget. Explicit cancellation, + // app shutdown, and the outer Agent request still stop the run. + if (event.type === "interaction" && timer) { + clearTimeout(timer); + timer = undefined; + } + yield event; + } + } + } + // Everything the plugin asked for while the stream was open must be + // answered before the call is treated as finished, or a request still + // in flight would find its response written to a closed pipe. + await Promise.all(inFlight); + } else { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of child.stdout) { + const buffer = Buffer.from(chunk); + bytes += buffer.byteLength; + if (bytes > session.config.maxOutputBytes) { + terminate(child); + throw new Error("Plugin command output exceeded size limit"); + } + chunks.push(buffer); + } + jsonOutput = Buffer.concat(chunks, bytes); + } + const { code, signal } = await exit; + if (timedOut) throw Object.assign(new Error("Plugin command timed out"), { code: "plugin_timeout" }); + if (inputError) throw inputError; + if (code !== 0) throw new Error(`Plugin command exited with ${code ?? signal}: ${stderr.trim() || "no stderr"}`); + if (jsonOutput) { + const output = JSON.parse(jsonOutput.toString("utf8")); + if (isCapabilityEvent(output)) terminal = CapabilityEventSchema.parse(output); + else if (Array.isArray(output?.events)) { + for (const rawEvent of output.events) { + const event = CapabilityEventSchema.parse(rawEvent); + if (event.type === "result" || event.type === "error") { + if (terminal) throw new Error("Plugin command emitted multiple terminal events"); + terminal = event; + } else { + yield event; + } + } + } else terminal = { type: "result", output }; + } + if (terminal) yield terminal; + } finally { + if (timer) clearTimeout(timer); + if (!child.stdin.destroyed && !child.stdin.writableEnded) child.stdin.end(); + session.children.delete(call.callId); + } + }, + + async respond(rawSession, callId, interactionId, response) { + const session = asCommandSession(rawSession); + const child = session.children.get(callId); + if (!session.config.interactive || !child || child.stdin.destroyed) { + throw new Error("Plugin command interaction is not available"); + } + await new Promise((resolve, reject) => { + child.stdin.write(`${JSON.stringify({ type: "interaction-response", callId, interactionId, response })}\n`, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + }, + + async cancel(rawSession, callId) { + const session = asCommandSession(rawSession); + abortHostServices(session, callId); + const child = session.children.get(callId); + if (child) { + terminate(child); + await waitForTermination(child); + } + }, + + async deactivate(rawSession) { + const session = asCommandSession(rawSession); + const children = [...session.children.values()]; + for (const callId of session.children.keys()) abortHostServices(session, callId); + for (const child of children) terminate(child); + await Promise.all(children.map(waitForTermination)); + session.children.clear(); + } + }; +} + +async function respondToHostServiceRequest( + child: ChildProcessWithoutNullStreams, + session: CommandPluginSession, + call: CapabilityCall, + request: PluginHostServiceRequest, + invoker: PluginHostServiceInvoker | undefined, + requestNumber: number +): Promise { + let message: PluginHostServiceResponse; + if (requestNumber > MAX_HOST_SERVICE_REQUESTS_PER_CALL) { + message = hostServiceError(call.callId, request.requestId, "host_service_limit_exceeded", `A plugin call may make at most ${MAX_HOST_SERVICE_REQUESTS_PER_CALL} Host-service requests`, false); + } else if (!session.config.interactive) { + message = hostServiceError(call.callId, request.requestId, "plugin_runtime_error", "Host services require an interactive command runtime", false); + } else if (!session.approvedHostServices.has(request.service)) { + message = hostServiceError(call.callId, request.requestId, "plugin_permission_denied", `Host service permission was not approved: ${request.service}`, false); + } else if (!invoker) { + message = hostServiceError(call.callId, request.requestId, "host_service_unavailable", `Host service is unavailable: ${request.service}`, true); + } else { + const controller = new AbortController(); + const controllers = session.hostServiceControllers.get(call.callId) ?? new Set(); + controllers.add(controller); + session.hostServiceControllers.set(call.callId, controllers); + try { + const response = await invoker.invoke({ + pluginId: session.pluginId, + callId: call.callId, + conversationId: call.conversationId, + modelPreset: call.modelPreset, + service: request.service, + input: request.input, + deadline: call.deadline, + signal: controller.signal + }); + message = { type: "host-service-response", callId: call.callId, requestId: request.requestId, response }; + } catch (error) { + const details = error as { code?: unknown; retryable?: unknown; message?: unknown }; + message = hostServiceError( + call.callId, + request.requestId, + typeof details.code === "string" ? details.code : "host_service_error", + typeof details.message === "string" ? details.message : "Host service request failed", + details.retryable === true + ); + } finally { + controllers.delete(controller); + if (controllers.size === 0) session.hostServiceControllers.delete(call.callId); + } + } + await writeChildMessage(child, message); +} + +function abortHostServices(session: CommandPluginSession, callId: string): void { + const controllers = session.hostServiceControllers.get(callId); + if (!controllers) return; + session.hostServiceControllers.delete(callId); + for (const controller of controllers) controller.abort(new Error("plugin_call_cancelled")); +} + +function hostServiceError(callId: string, requestId: string, code: string, message: string, retryable: boolean): PluginHostServiceResponse { + return { type: "host-service-response", callId, requestId, error: { code, message, retryable } }; +} + +async function writeChildMessage(child: ChildProcessWithoutNullStreams, message: PluginHostServiceResponse): Promise { + await new Promise((resolve, reject) => { + child.stdin.write(`${JSON.stringify(message)}\n`, (error) => error ? reject(error) : resolve()); + }); +} + +function isSandboxablePlatform(platform: NodeJS.Platform): boolean { + return platform === "darwin" || platform === "linux" || platform === "win32"; +} + +function validateCommandConfig(runtime: PluginRuntime, rootPath: string | null, platform: NodeJS.Platform): CommandRuntimeConfig { + if (runtime.adapter !== "command") throw new Error(`Expected command runtime, got ${runtime.adapter}`); + if (!rootPath) throw new Error("Command plugin requires an installed artifact"); + const config = CommandRuntimeConfigSchema.parse(runtime.config ?? {}); + if (config.sandbox === "required" && !isSandboxablePlatform(platform)) { + throw new Error(`Sandboxed command plugins are unsupported on ${platform}`); + } + if (isAbsolute(config.command) || config.command.split(/[\\/]/).includes("..")) { + throw new Error("Plugin command must be relative to its artifact root"); + } + return config; +} + +/** Resolves the child process entrypoint without any sandbox wrapper. */ +async function resolveCommandEntrypoint( + context: PluginRuntimeContext, + config: Pick & Partial> +): Promise<{ root: string; runtimeCommand: string; runtimeArgs: string[]; cwd: string; interpreter: "direct" | "node" }> { + const root = await realpath(context.rootPath!); + const command = await canonicalDescendant(root, resolve(root, config.command)); + const info = await lstat(command); + if (!info.isFile() || info.isSymbolicLink()) throw new Error("Plugin command must be a regular file"); + const interpreter = config.interpreter ?? "direct"; + if (interpreter === "direct") await access(command, fsConstants.X_OK); + const runtimeCommand = interpreter === "node" ? await realpath(process.execPath) : command; + const runtimeArgs = interpreter === "node" ? [command, ...config.args] : config.args; + const cwd = await canonicalDescendant(root, resolve(root, config.cwd), true); + if (!(await lstat(cwd)).isDirectory()) throw new Error("Plugin command cwd must be a directory"); + return { root, runtimeCommand, runtimeArgs, cwd, interpreter }; +} + +/** + * Launches a Host-distributed plugin directly. The artifact path checks are + * kept because they guard against a tampered package pointing outside its own + * root; only the OS sandbox wrapper is dropped. Environment scrubbing, secret + * injection and the network permission check all live in `activate` and still + * apply. + */ +export async function buildDirectLaunch( + context: PluginRuntimeContext, + config: Pick & Partial> +): Promise { + const { runtimeCommand, runtimeArgs, cwd } = await resolveCommandEntrypoint(context, config); + return { command: runtimeCommand, args: runtimeArgs, cwd }; +} + +export async function buildPluginSandboxLaunch( + context: PluginRuntimeContext, + config: Pick & Partial>, + platform: NodeJS.Platform = process.platform, + networkEnabled = false, + fileInputRoots: readonly string[] = [], + pluginDataRoot?: string, + runtimeReadRoots: readonly string[] = [], + runtimeExecutableRoots: readonly string[] = [] +): Promise { + const { root, runtimeCommand, runtimeArgs, cwd, interpreter } = await resolveCommandEntrypoint(context, config); + const runtimeRoot = interpreter === "node" ? (platform === "win32" ? dirname(runtimeCommand) : resolve(dirname(runtimeCommand), "..")) : null; + const filesystem = await filesystemRules(context, fileInputRoots, pluginDataRoot); + + if (platform === "win32") { + if (networkEnabled) throw new Error("Windows AppContainer plugins currently require no network capability"); + let script = fileURLToPath(new URL("./windows/launch.ps1", import.meta.url)); + script = script.replace(/([\\/])app\.asar([\\/])/, "$1app.asar.unpacked$2"); + await access(script, fsConstants.R_OK); + await access(resolve(dirname(script), "Sandbox.cs"), fsConstants.R_OK); + const plan = { + profile: `memmy.plugin.${createHash("sha256").update(`${context.plugin.id}\0${root}`).digest("hex").slice(0, 32)}`, + // The Host has already canonicalized the entrypoint. Node's default CJS + // resolver otherwise lstats every ancestor up to C:\, outside the grant. + command: runtimeCommand, args: interpreter === "node" ? ["--preserve-symlinks", "--preserve-symlinks-main", ...runtimeArgs] : runtimeArgs, cwd, + readRoots: [...new Set([root, ...(runtimeRoot ? [runtimeRoot] : []), ...runtimeReadRoots, + ...filesystem.filter(rule => !rule.writable).map(rule => rule.path)])], + writeRoots: [...new Set(filesystem.filter(rule => rule.writable).map(rule => rule.path))] + }; + return { + command: win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), + windowsAppContainer: true, + args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, + "-Plan", Buffer.from(JSON.stringify(plan)).toString("base64")], cwd + }; + } + + if (platform === "darwin") { + const ipcRoot = await resolvePluginDataPath(context, pluginDataRoot); + return { + command: "/usr/bin/sandbox-exec", + args: ["-p", seatbeltProfile(root, filesystem, networkEnabled, runtimeRoot, runtimeReadRoots, runtimeExecutableRoots, ipcRoot), "--", runtimeCommand, ...runtimeArgs], + cwd + }; + } + + const bwrap = await firstExecutable(["/usr/bin/bwrap", "/bin/bwrap"]); + if (!bwrap) throw new Error("Command plugins require bubblewrap on Linux"); + return { command: bwrap, args: bwrapArgs(root, cwd, runtimeCommand, runtimeArgs, filesystem, networkEnabled, runtimeRoot, runtimeReadRoots), cwd }; +} + +export interface ResolvedCommandRuntimeDependencies { + pathEntries: string[]; + readRoots: string[]; + executableRoots: string[]; + environment: Record; +} + +/** Resolve only Host-approved runtimes; plugin manifests cannot inject arbitrary executable roots. */ +export async function resolveCommandRuntimeDependencies( + dependencies: readonly z.infer[], + platform: NodeJS.Platform = process.platform, + officeInstaller?: OfficeRuntimeInstaller +): Promise { + const resolved: ResolvedCommandRuntimeDependencies = { pathEntries: [], readRoots: [], executableRoots: [], environment: {} }; + if (dependencies.includes("office")) { + const office = await resolveOfficeRuntime(platform, officeInstaller); + resolved.pathEntries.push(...office.pathEntries); + resolved.readRoots.push(...office.readRoots); + resolved.executableRoots.push(...office.executableRoots); + Object.assign(resolved.environment, office.environment); + } + if (!dependencies.includes("texlive")) return resolved; + + if (platform === "darwin") { + const binaryRoot = await realpath("/Library/TeX/texbin").catch(() => null); + const distributionRoot = await realpath("/usr/local/texlive").catch(() => null); + const userTexmfRoot = await realpath(resolve(homedir(), "Library", "texmf")).catch(() => null); + if (binaryRoot) resolved.pathEntries.push(binaryRoot); + if (distributionRoot) { + resolved.readRoots.push(distributionRoot); + resolved.executableRoots.push(distributionRoot); + } + if (userTexmfRoot) { + resolved.readRoots.push(userTexmfRoot); + resolved.environment.TEXMFHOME = userTexmfRoot; + } + } + return resolved; +} + +/** Office is an optional Host dependency, never copied back into the App bundle. + * Only the Host environment or known installation roots can grant executable + * access. A plugin's manifest env/config cannot add roots to the OS sandbox. + */ +export async function resolveOfficeRuntime(platform: NodeJS.Platform = process.platform, installer?: OfficeRuntimeInstaller): Promise { + const result: ResolvedCommandRuntimeDependencies = { pathEntries: [], readRoots: [], executableRoots: [], environment: {} }; + const override = process.env.MEMMY_PLUGIN_OFFICE_ROOT; + if (override && !isAbsolute(override)) throw new Error("MEMMY_PLUGIN_OFFICE_ROOT must be absolute"); + const defaults = platform === "darwin" + ? ["/Applications/LibreOffice.app", "/opt/homebrew", "/usr/local"] + : platform === "linux" ? ["/usr", "/opt/libreoffice"] + : platform === "win32" ? [win32.join(process.env.ProgramFiles ?? "C:\\Program Files", "LibreOffice")] : []; + const roots = process.env.MEMMY_OFFICE_RUNTIME_PREFER_MANAGED === "1" && installer ? [] : [...(override ? [override] : []), ...defaults]; + const tools: Record = {}; + for (const configured of roots) { + const root = await realpath(configured).catch(() => null); + if (!root) continue; + for (const name of ["soffice", "pdfinfo", "pdftoppm", "pdftotext"]) { + if (tools[name]) continue; + // Desktop soffice uses global named pipes that AppContainer rejects. + // Windows requires the headless LibreOfficeKit bridge in our runtime. + const binaryName = platform === "win32" ? name === "soffice" ? "memmy-office-convert.exe" : `${name}.exe` : name; + const candidates = [...(configured === override && name === "soffice" + ? [resolve(root, "native", "libreoffice-headless", "libreoffice", "LibreOfficeDev.app", "Contents", "MacOS", binaryName)] : []), + resolve(root, "bin", binaryName), resolve(root, "MacOS", binaryName), resolve(root, "Contents", "MacOS", binaryName), resolve(root, "program", binaryName), + resolve(root, "libreoffice", "program", binaryName), resolve(root, "poppler", "Library", "bin", binaryName), + resolve(root, "bin", "override", binaryName), resolve(root, "native", "poppler", "bin", binaryName)]; + if (configured === override) candidates.push(resolve(root, "native", "poppler", "poppler", "bin", binaryName)); + const binary = await firstExecutable(candidates); + if (!binary) continue; + const canonical = await realpath(binary); + tools[name] = canonical; + // Homebrew's bin symlinks resolve inside Cellar. The Host-approved root + // includes associated dylibs, fonts and shared resources as well. + result.readRoots.push(root); + result.executableRoots.push(root); + result.pathEntries.push(dirname(canonical)); + } + } + if (["soffice", "pdfinfo", "pdftoppm", "pdftotext"].some(name => !tools[name]) && installer) { + const installed = await installer.ensure(platform, process.arch); + Object.assign(tools, installed.tools); + result.readRoots.push(installed.root); result.executableRoots.push(installed.root); + result.pathEntries.push(...Object.values(installed.tools).map(file => dirname(file))); + } + result.readRoots = [...new Set(result.readRoots)]; + result.executableRoots = [...new Set(result.executableRoots)]; + result.pathEntries = [...new Set(result.pathEntries)]; + result.environment.MEMMY_OFFICE_TOOLS = JSON.stringify(tools); + return result; +} + +function defaultOfficeInstaller(pluginDataRoot?: string): OfficeRuntimeInstaller | undefined { + if (!pluginDataRoot) return undefined; + const registry = resolvePluginRegistryBaseUrl(process.env); + const catalogUrl = process.env.MEMMY_OFFICE_RUNTIME_CATALOG_URL + ?? (registry ? new URL("/api/v1/plugins/office/runtimes/{target}", registry).href : undefined); + if (!catalogUrl) return undefined; + return createOfficeRuntimeInstaller({ cacheRoot: process.env.MEMMY_OFFICE_RUNTIME_CACHE ?? resolve(dirname(pluginDataRoot), "plugin-runtimes", "office"), catalogUrl }); +} + +interface FilesystemRule { + path: string; + writable: boolean; +} + +async function filesystemRules( + context: PluginRuntimeContext, + fileInputRoots: readonly string[], + pluginDataRoot?: string +): Promise { + const rules: FilesystemRule[] = []; + for (const permission of context.plugin.approvedPermissions) { + if (permission.type !== "filesystem") continue; + for (const configured of permission.paths) { + if (!isAbsolute(configured)) throw new Error(`Filesystem permission path must be absolute: ${configured}`); + const path = await realpath(configured); + rules.push({ path, writable: permission.access !== "read" }); + } + } + if (approvedHostService(context, "file-input")) { + for (const configured of fileInputRoots) { + if (!isAbsolute(configured)) throw new Error(`File-input root must be absolute: ${configured}`); + const path = await realpath(configured).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (path) rules.push({ path, writable: false }); + } + } + const pluginDataPath = await resolvePluginDataPath(context, pluginDataRoot); + if (pluginDataPath) { + rules.push({ path: pluginDataPath, writable: true }); + } + return rules; +} + +function approvedHostService(context: PluginRuntimeContext, service: string): boolean { + return context.plugin.approvedPermissions.some((permission) => permission.type === "host-service" && permission.services.includes(service)); +} + +async function resolvePluginDataPath(context: PluginRuntimeContext, configuredRoot: string | undefined): Promise { + if (!approvedHostService(context, "plugin-data")) return null; + if (!configuredRoot || !isAbsolute(configuredRoot)) { + throw new Error("plugin-data host service requires a Host-owned absolute data root"); + } + await mkdir(configuredRoot, { recursive: true }); + const root = await realpath(configuredRoot); + const target = resolve(root, context.plugin.id); + const child = relative(root, target); + if (!child || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) { + throw new Error("Plugin data path escapes the Host-owned data root"); + } + await mkdir(target, { recursive: true }); + return realpath(target); +} + +function seatbeltProfile( + root: string, + filesystem: FilesystemRule[], + networkEnabled: boolean, + runtimeRoot: string | null, + runtimeReadRoots: readonly string[], + runtimeExecutableRoots: readonly string[], + ipcRoot: string | null +): string { + const readPaths = [ + root, + "/System", + "/System/Volumes/Preboot/Cryptexes/OS", + "/Library/Apple", + "/Library/Fonts", + "/usr/lib", + "/usr/libexec", + "/usr/share", + "/bin", + "/usr/bin", + ...filesystem.map((rule) => rule.path), + ...(runtimeRoot ? [runtimeRoot] : []), + ...runtimeReadRoots + ]; + const writePaths = filesystem.filter((rule) => rule.writable).map((rule) => rule.path); + const clauses = [ + "(version 1)", + "(deny default)", + "(import \"system.sb\")", + "(allow process-exec process-fork)", + "(allow signal process-info* (target same-sandbox))", + `(allow file-read-metadata file-test-existence ${readPaths.map(seatbeltAncestors).join(" ")})`, + `(allow file-map-executable (subpath "/System") (subpath "/System/Volumes/Preboot/Cryptexes/OS") (subpath "/usr/lib") (subpath "/Library/Apple") ${seatbeltSubpath(root)}${runtimeRoot ? ` ${seatbeltSubpath(runtimeRoot)}` : ""}${runtimeExecutableRoots.map((path) => ` ${seatbeltSubpath(path)}`).join("")})`, + `(allow file-read* ${readPaths.map(seatbeltSubpath).join(" ")})` + ]; + if (writePaths.length) clauses.push(`(allow file-write* ${writePaths.map(seatbeltSubpath).join(" ")})`); + if (ipcRoot) clauses.push( + "(allow system-socket (socket-domain AF_UNIX))", + `(allow network-bind network-inbound (local unix-socket ${seatbeltSubpath(ipcRoot)}))`, + `(allow network-outbound (remote unix-socket ${seatbeltSubpath(ipcRoot)}))` + ); + if (networkEnabled) clauses.push("(allow network-outbound)"); + return clauses.join("\n"); +} + +function seatbeltSubpath(path: string): string { + return `(subpath ${JSON.stringify(path)})`; +} + +function seatbeltAncestors(path: string): string { + return `(path-ancestors ${JSON.stringify(path)})`; +} + +function bwrapArgs( + root: string, + cwd: string, + command: string, + commandArgs: string[], + filesystem: FilesystemRule[], + networkEnabled: boolean, + runtimeRoot: string | null, + runtimeReadRoots: readonly string[] +): string[] { + const args = ["--die-with-parent", "--new-session", "--unshare-all", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]; + if (networkEnabled) args.push("--share-net"); + const bindTargets = [root, ...filesystem.map((rule) => rule.path), ...(runtimeRoot ? [runtimeRoot] : []), ...runtimeReadRoots]; + for (const directory of new Set(bindTargets.flatMap(parentDirectories))) args.push("--dir", directory); + for (const path of ["/usr", "/bin", "/lib", "/lib64"]) args.push("--ro-bind-try", path, path); + args.push("--ro-bind", root, root); + if (runtimeRoot && !["/usr", "/bin", "/lib", "/lib64"].includes(runtimeRoot)) args.push("--ro-bind", runtimeRoot, runtimeRoot); + for (const path of runtimeReadRoots) args.push("--ro-bind", path, path); + for (const rule of filesystem) args.push(rule.writable ? "--bind" : "--ro-bind", rule.path, rule.path); + args.push("--chdir", cwd, "--", command, ...commandArgs); + return args; +} + +function parentDirectories(path: string): string[] { + const parts = resolve(path).split(sep).filter(Boolean); + const directories: string[] = []; + let current: string = sep; + for (const part of parts.slice(0, -1)) { + current = resolve(current, part); + if (!["/usr", "/bin", "/lib", "/lib64"].includes(current)) directories.push(current); + } + return directories; +} + +async function canonicalDescendant(root: string, path: string, allowRoot = false): Promise { + const canonical = await realpath(path); + const child = relative(root, canonical); + if ((!allowRoot && !child) || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) { + throw new Error("Plugin command path escapes its artifact root"); + } + return canonical; +} + +async function firstExecutable(paths: string[]): Promise { + for (const path of paths) { + try { + await access(path, fsConstants.X_OK); + return path; + } catch { + continue; + } + } + return null; +} + +export function resolvePluginEnvironment( + configured: Readonly>, + secretEnv: Readonly>, + secrets: Readonly>, + runtimePathEntries: readonly string[] = [], + runtimeEnvironment: Readonly> = {} +): Record { + const windowsRoot = process.env.SystemRoot ?? "C:\\Windows"; + const hostPath = [...new Set([...runtimePathEntries, ...(process.platform === "win32" ? [win32.join(windowsRoot, "System32"), windowsRoot] : ["/usr/bin", "/bin"])])].join(process.platform === "win32" ? ";" : ":"); + const env: Record = { PATH: hostPath, LANG: "C.UTF-8", + ...(process.platform === "win32" ? { SystemRoot: windowsRoot, WINDIR: windowsRoot, ComSpec: win32.join(windowsRoot, "System32", "cmd.exe") } : {}), + ...configured, ...runtimeEnvironment }; + for (const [name, key] of Object.entries(secretEnv)) { + const value = secrets[key]; + if (!value) throw new Error(`Missing plugin secret for environment variable ${name}`); + env[name] = value; + } + return env; +} + +/** Never let plugin-supplied CLR/PowerShell variables affect the privileged launcher. */ +function windowsLauncherEnvironment(childEnvironment: Record): Record { + const systemRoot = process.env.SystemRoot ?? "C:\\Windows"; + return { SystemRoot: systemRoot, WINDIR: systemRoot, + PATH: win32.join(systemRoot, "System32"), + PSModulePath: win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "Modules"), + ...(process.env.MEMMY_PLUGIN_SANDBOX_TRACE === "1" ? { MEMMY_SANDBOX_TRACE: "1" } : {}), + MEMMY_SANDBOX_CHILD_ENV: Buffer.from(JSON.stringify(childEnvironment)).toString("base64") }; +} + +function applyPlaceholders(value: string, call: CapabilityCall): string { + return value + .replaceAll("{capabilityId}", call.capabilityId) + .replaceAll("{callId}", call.callId) + .replaceAll("{conversationId}", call.conversationId); +} + +function processExit(child: ChildProcessWithoutNullStreams): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }); +} + +function terminate(child: ChildProcessWithoutNullStreams): void { + if (child.exitCode !== null || child.killed) return; + child.kill("SIGTERM"); + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill("SIGKILL"); + }, 5_000); + timer.unref(); +} + +function waitForTermination(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(resolve, 5_500); + timer.unref(); + child.once("close", () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +function asCommandSession(session: PluginSession): CommandPluginSession { + if (!("children" in session)) throw new Error("Invalid command plugin session"); + return session as CommandPluginSession; +} diff --git a/App/backend/src/adapters/outbound/plugin-runtime/http-adapter.ts b/App/backend/src/adapters/outbound/plugin-runtime/http-adapter.ts new file mode 100644 index 000000000..b26c32066 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/http-adapter.ts @@ -0,0 +1,236 @@ +/** HTTP/SSE plugin runtime adapter. */ +import { + CapabilityEventSchema, + type CapabilityCall, + type CapabilityEvent, + type PluginRuntime +} from "@memmy/local-api-contracts"; +import { z } from "zod"; +import { + assertNetworkPermission, + assertSafeStaticHeaders, + callTimeoutMs, + isCapabilityEvent, + resolveSecretHeaders +} from "./shared.js"; +import type { PluginAdapter, PluginRuntimeContext, PluginSession } from "./types.js"; + +const DEFAULT_TIMEOUT_MS = 300_000; +const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; +const ABSOLUTE_MAX_RESPONSE_BYTES = 50 * 1024 * 1024; +const HttpRuntimeConfigSchema = z.object({ + baseUrl: z.string().url(), + invokePath: z.string().min(1).default("/capabilities/{capabilityId}/invoke"), + cancelPath: z.string().min(1).optional(), + interactionPath: z.string().min(1).default("/calls/{callId}/interactions/{interactionId}"), + headers: z.record(z.string(), z.string()).default({}), + secretHeaders: z.record(z.string(), z.string().min(1)).default({}), + timeoutMs: z.number().int().positive().max(3_600_000).default(DEFAULT_TIMEOUT_MS), + maxResponseBytes: z.number().int().positive().max(ABSOLUTE_MAX_RESPONSE_BYTES).default(DEFAULT_MAX_RESPONSE_BYTES) +}); +type HttpRuntimeConfig = z.infer; + +interface HttpPluginSession extends PluginSession { + config: HttpRuntimeConfig; + pluginConfig: Readonly>; + headers: Record; + controllers: Map; +} + +export interface CreateHttpPluginAdapterOptions { + fetchFn?: typeof fetch; +} + +export function createHttpPluginAdapter(options: CreateHttpPluginAdapterOptions = {}): PluginAdapter { + const fetchFn = options.fetchFn ?? fetch.bind(globalThis); + + return { + id: "http", + + validate(runtime) { + validateHttpConfig(runtime); + }, + + async activate(context) { + const config = validateHttpConfig(context.plugin.manifest.runtime); + const baseUrl = new URL(config.baseUrl); + assertAllowedProtocol(baseUrl); + assertNetworkPermission(context, baseUrl.hostname); + const headers = resolveSecretHeaders(config.headers, config.secretHeaders, context.secrets, "HTTP"); + return { + pluginId: context.plugin.id, + config, + pluginConfig: context.config, + headers, + controllers: new Map() + } satisfies HttpPluginSession; + }, + + async *invoke(rawSession, call) { + const session = asHttpSession(rawSession); + const controller = new AbortController(); + session.controllers.set(call.callId, controller); + const timeoutMs = callTimeoutMs(session.config.timeoutMs, call.deadline); + const timer = setTimeout(() => controller.abort(timeoutError()), timeoutMs); + try { + const response = await fetchFn(resolveEndpoint(session.config, session.config.invokePath, { + capabilityId: call.capabilityId, + callId: call.callId + }), { + method: "POST", + redirect: "error", + headers: { "content-type": "application/json", accept: "application/json, application/x-ndjson, text/event-stream", ...session.headers }, + body: JSON.stringify({ + callId: call.callId, + conversationId: call.conversationId, + input: call.input, + deadline: call.deadline, + config: session.pluginConfig + }), + signal: controller.signal + }); + if (!response.ok) throw new Error(`Plugin HTTP request failed with ${response.status}`); + yield* responseEvents(response, session.config.maxResponseBytes); + } catch (error) { + if (controller.signal.aborted && controller.signal.reason instanceof Error) throw controller.signal.reason; + throw error; + } finally { + clearTimeout(timer); + session.controllers.delete(call.callId); + } + }, + + async cancel(rawSession, callId) { + const session = asHttpSession(rawSession); + session.controllers.get(callId)?.abort(Object.assign(new Error("Plugin call cancelled"), { name: "AbortError" })); + if (!session.config.cancelPath) return; + const response = await fetchFn(resolveEndpoint(session.config, session.config.cancelPath, { callId }), { + method: "POST", + redirect: "error", + headers: { "content-type": "application/json", ...session.headers }, + body: JSON.stringify({ callId }), + signal: AbortSignal.timeout(Math.min(session.config.timeoutMs, 30_000)) + }); + if (!response.ok) throw new Error(`Plugin HTTP cancellation failed with ${response.status}`); + }, + + async respond(rawSession, callId, interactionId, responseBody) { + const session = asHttpSession(rawSession); + const response = await fetchFn(resolveEndpoint(session.config, session.config.interactionPath, { + callId, + interactionId + }), { + method: "POST", + redirect: "error", + headers: { "content-type": "application/json", ...session.headers }, + body: JSON.stringify({ callId, interactionId, response: responseBody }), + signal: AbortSignal.timeout(Math.min(session.config.timeoutMs, 30_000)) + }); + if (!response.ok) throw new Error(`Plugin HTTP interaction response failed with ${response.status}`); + }, + + async deactivate(rawSession) { + const session = asHttpSession(rawSession); + for (const controller of session.controllers.values()) controller.abort(new Error("Plugin disabled")); + session.controllers.clear(); + } + }; +} + +function validateHttpConfig(runtime: PluginRuntime): HttpRuntimeConfig { + if (runtime.adapter !== "http") throw new Error(`Expected http runtime, got ${runtime.adapter}`); + const config = HttpRuntimeConfigSchema.parse(runtime.config ?? {}); + assertSafeStaticHeaders(config.headers, "HTTP"); + return config; +} + +function assertAllowedProtocol(url: URL): void { + const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("Plugin HTTP baseUrl must use HTTPS or loopback HTTP"); + } +} + +function resolveEndpoint(config: HttpRuntimeConfig, template: string, values: Record): URL { + const base = new URL(config.baseUrl); + const path = Object.entries(values).reduce( + (result, [name, value]) => result.replaceAll(`{${name}}`, encodeURIComponent(value)), + template + ); + const endpoint = new URL(path, base); + if (endpoint.origin !== base.origin) throw new Error("Plugin HTTP endpoint must stay on baseUrl origin"); + return endpoint; +} + +async function* responseEvents(response: Response, maxBytes: number): AsyncIterable { + const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + throw new Error("Plugin HTTP response exceeded size limit"); + } + if (contentType === "application/x-ndjson" || contentType === "application/ndjson" || contentType === "text/event-stream") { + if (!response.body) throw new Error("Plugin HTTP response body is empty"); + let bytes = 0; + let buffer = ""; + const decoder = new TextDecoder(); + for await (const chunk of response.body) { + bytes += chunk.byteLength; + if (bytes > maxBytes) throw new Error("Plugin HTTP response exceeded size limit"); + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const data = eventData(line, contentType); + if (data) yield CapabilityEventSchema.parse(JSON.parse(data)); + } + } + buffer += decoder.decode(); + const data = eventData(buffer.trim(), contentType); + if (data) yield CapabilityEventSchema.parse(JSON.parse(data)); + return; + } + + const bytes = await readResponseBytes(response, maxBytes); + const body = JSON.parse(new TextDecoder().decode(bytes)); + if (Array.isArray(body?.events)) { + for (const event of body.events) yield CapabilityEventSchema.parse(event); + } else if (isCapabilityEvent(body)) { + yield CapabilityEventSchema.parse(body); + } else { + yield { type: "result", output: body }; + } +} + +async function readResponseBytes(response: Response, maxBytes: number): Promise { + if (!response.body) return new Uint8Array(); + const chunks: Uint8Array[] = []; + let total = 0; + for await (const chunk of response.body) { + total += chunk.byteLength; + if (total > maxBytes) throw new Error("Plugin HTTP response exceeded size limit"); + chunks.push(chunk); + } + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function eventData(line: string, contentType: string | undefined): string | null { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith(":")) return null; + if (contentType === "text/event-stream") return trimmed.startsWith("data:") ? trimmed.slice(5).trim() : null; + return trimmed; +} + +function timeoutError(): Error { + return Object.assign(new Error("Plugin HTTP request timed out"), { code: "plugin_timeout" }); +} + +function asHttpSession(session: PluginSession): HttpPluginSession { + if (!("controllers" in session)) throw new Error("Invalid HTTP plugin session"); + return session as HttpPluginSession; +} diff --git a/App/backend/src/adapters/outbound/plugin-runtime/index.ts b/App/backend/src/adapters/outbound/plugin-runtime/index.ts new file mode 100644 index 000000000..ea5b1781c --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/index.ts @@ -0,0 +1,14 @@ +export { PluginAdapterRegistry } from "./registry.js"; +export { createPluginRuntimeHost } from "./runtime-host.js"; +export { createHttpPluginAdapter, type CreateHttpPluginAdapterOptions } from "./http-adapter.js"; +export { createMcpPluginAdapter, type CreateMcpPluginAdapterOptions } from "./mcp-adapter.js"; +export { createCommandPluginAdapter, type CreateCommandPluginAdapterOptions } from "./command-adapter.js"; +export type { + PluginAdapter, + PluginRuntimeContext, + PluginRuntimeHost, + PluginRuntimeRecord, + PluginSession, + PluginHostServiceCall, + PluginHostServiceInvoker +} from "./types.js"; diff --git a/App/backend/src/adapters/outbound/plugin-runtime/mcp-adapter.ts b/App/backend/src/adapters/outbound/plugin-runtime/mcp-adapter.ts new file mode 100644 index 000000000..0495891fd --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/mcp-adapter.ts @@ -0,0 +1,286 @@ +/** Remote MCP plugin runtime adapter. */ +import { + CapabilityEventSchema, + type CapabilityCall, + type CapabilityEvent, + type PluginRuntime +} from "@memmy/local-api-contracts"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { z } from "zod"; +import { + assertNetworkPermission, + assertSafeStaticHeaders, + callTimeoutMs, + resolveSecretHeaders +} from "./shared.js"; +import type { PluginAdapter, PluginRuntimeContext, PluginSession } from "./types.js"; +import { buildPluginSandboxLaunch, resolvePluginEnvironment } from "./command-adapter.js"; + +const McpSharedConfigSchema = z.object({ + capabilityTools: z.record(z.string(), z.string().min(1)).default({}), + timeoutMs: z.number().int().positive().max(3_600_000).default(300_000) +}); +const McpRemoteConfigSchema = McpSharedConfigSchema.extend({ + transport: z.enum(["sse", "streamableHttp"]), + url: z.string().url(), + headers: z.record(z.string(), z.string()).default({}), + secretHeaders: z.record(z.string(), z.string().min(1)).default({}) +}); +const McpStdioConfigSchema = McpSharedConfigSchema.extend({ + transport: z.literal("stdio"), + command: z.string().trim().min(1), + args: z.array(z.string()).default([]), + cwd: z.string().default("."), + env: z.record(z.string(), z.string()).default({}), + secretEnv: z.record(z.string(), z.string().min(1)).default({}) +}); +const McpRuntimeConfigSchema = z.discriminatedUnion("transport", [McpRemoteConfigSchema, McpStdioConfigSchema]); +type McpRuntimeConfig = z.infer; + +interface McpClientLike { + connect(transport: unknown): Promise; + close(): Promise; + listTools(): Promise<{ tools: Array<{ name: string }> }>; + callTool( + params: { name: string; arguments: Record }, + resultSchema?: undefined, + options?: { signal?: AbortSignal; timeout?: number; onprogress?: (progress: McpProgress) => void } + ): Promise; +} + +interface McpProgress { + progress: number; + total?: number; + message?: string; +} + +interface McpPluginSession extends PluginSession { + client: McpClientLike; + config: McpRuntimeConfig; + capabilityTools: Record; + controllers: Map; +} + +export interface CreateMcpPluginAdapterOptions { + createClient?: () => McpClientLike; + createTransport?: ( + config: McpRuntimeConfig, + headers: Record, + context: PluginRuntimeContext + ) => unknown | Promise; + platform?: NodeJS.Platform; +} + +export function createMcpPluginAdapter(options: CreateMcpPluginAdapterOptions = {}): PluginAdapter { + const createClient = options.createClient ?? (() => new Client({ name: "memmy-plugin-host", version: "1.0.0" }) as McpClientLike); + const createTransport = options.createTransport ?? defaultTransport; + const platform = options.platform ?? process.platform; + + return { + id: "mcp", + + validate(runtime, rootPath) { + validateMcpConfig(runtime, rootPath, platform); + }, + + async activate(context) { + const config = validateMcpConfig(context.plugin.manifest.runtime, context.rootPath, platform); + const headers = config.transport === "stdio" + ? {} + : resolveSecretHeaders(config.headers, config.secretHeaders, context.secrets, "MCP"); + if (config.transport === "stdio") { + if (context.plugin.manifest.permissions.some((permission) => permission.type === "network")) { + throw Object.assign(new Error("Local MCP plugins cannot request network access; use remote MCP"), { + code: "plugin_permission_denied" + }); + } + } else { + const url = new URL(config.url); + assertAllowedProtocol(url); + assertNetworkPermission(context, url.hostname); + } + const client = createClient(); + try { + await client.connect(await createTransport(config, headers, context)); + const available = new Set((await client.listTools()).tools.map((tool) => tool.name)); + const capabilityTools = Object.fromEntries(context.plugin.manifest.capabilities.map((capability) => [ + capability.id, + config.capabilityTools[capability.id] ?? capability.id + ])); + const missing = Object.values(capabilityTools).filter((tool) => !available.has(tool)); + if (missing.length) throw new Error(`MCP tools not found: ${missing.join(", ")}`); + return { + pluginId: context.plugin.id, + client, + config, + capabilityTools, + controllers: new Map() + } satisfies McpPluginSession; + } catch (error) { + await client.close().catch(() => undefined); + throw error; + } + }, + + async *invoke(rawSession, call) { + const session = asMcpSession(rawSession); + const toolName = session.capabilityTools[call.capabilityId]; + if (!toolName) throw new Error(`MCP capability is not mapped: ${call.capabilityId}`); + const controller = new AbortController(); + session.controllers.set(call.callId, controller); + const progress: CapabilityEvent[] = []; + let wake: (() => void) | null = null; + let settled = false; + let result: unknown; + let failure: unknown; + const request = session.client.callTool( + { name: toolName, arguments: asArguments(call.input) }, + undefined, + { + signal: controller.signal, + timeout: callTimeoutMs(session.config.timeoutMs, call.deadline), + onprogress(update) { + progress.push({ + type: "progress", + current: update.progress, + ...(update.total === undefined ? {} : { total: update.total }), + ...(update.message === undefined ? {} : { message: update.message }) + }); + wake?.(); + wake = null; + } + } + ).then((value) => { + result = value; + }, (error) => { + failure = error; + }).finally(() => { + settled = true; + wake?.(); + wake = null; + }); + + try { + while (!settled || progress.length) { + const event = progress.shift(); + if (event) { + yield event; + } else { + await new Promise((resolve) => { + wake = resolve; + }); + } + } + await request; + if (failure) throw failure; + yield* mcpResultEvents(result); + } finally { + session.controllers.delete(call.callId); + } + }, + + async cancel(rawSession, callId) { + asMcpSession(rawSession).controllers.get(callId)?.abort(new Error("Plugin call cancelled")); + }, + + async deactivate(rawSession) { + const session = asMcpSession(rawSession); + for (const controller of session.controllers.values()) controller.abort(new Error("Plugin disabled")); + session.controllers.clear(); + await session.client.close(); + } + }; +} + +function validateMcpConfig( + runtime: PluginRuntime, + rootPath: string | null, + platform: NodeJS.Platform +): McpRuntimeConfig { + if (runtime.adapter !== "mcp") throw new Error(`Expected mcp runtime, got ${runtime.adapter}`); + const config = McpRuntimeConfigSchema.parse(runtime.config ?? {}); + if (config.transport === "stdio") { + if (!rootPath) throw new Error("Local MCP plugin requires an installed artifact"); + if (platform !== "darwin" && platform !== "linux") throw new Error(`Local MCP plugins are unsupported on ${platform}`); + } else { + assertSafeStaticHeaders(config.headers, "MCP"); + } + return config; +} + +async function defaultTransport( + config: McpRuntimeConfig, + headers: Record, + context: PluginRuntimeContext +): Promise { + if (config.transport === "stdio") { + const launch = await buildPluginSandboxLaunch(context, config); + return new StdioClientTransport({ + ...launch, + env: resolvePluginEnvironment(config.env, config.secretEnv, context.secrets), + stderr: "ignore" + }); + } + const url = new URL(config.url); + const rejectRedirects = (input: string | URL | Request, init?: RequestInit) => + fetch(input, { ...init, redirect: "error" }); + const requestInit = { headers }; + if (config.transport === "sse") { + return new SSEClientTransport(url, { + requestInit, + eventSourceInit: { fetch: rejectRedirects } + }); + } + return new StreamableHTTPClientTransport(url, { requestInit, fetch: rejectRedirects }); +} + +async function* mcpResultEvents(value: unknown): AsyncIterable { + const result = value as { + isError?: boolean; + structuredContent?: unknown; + content?: Array<{ type?: string; text?: string }>; + }; + if (result?.isError) { + yield { type: "error", code: "plugin_runtime_error", message: contentText(result.content) || "MCP tool failed", retryable: false }; + return; + } + const structured = result?.structuredContent; + if (structured && typeof structured === "object" && Array.isArray((structured as { events?: unknown }).events)) { + for (const event of (structured as { events: unknown[] }).events) yield CapabilityEventSchema.parse(event); + return; + } + if (structured !== undefined) { + yield { type: "result", output: structured }; + return; + } + const text = contentText(result?.content); + try { + yield { type: "result", output: JSON.parse(text) }; + } catch { + yield { type: "result", output: text }; + } +} + +function contentText(content: Array<{ type?: string; text?: string }> | undefined): string { + return (content ?? []).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n"); +} + +function asArguments(input: unknown): Record { + if (!input || typeof input !== "object" || Array.isArray(input)) return { input }; + return input as Record; +} + +function assertAllowedProtocol(url: URL): void { + const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("Plugin MCP URL must use HTTPS or loopback HTTP"); + } +} + +function asMcpSession(session: PluginSession): McpPluginSession { + if (!("client" in session)) throw new Error("Invalid MCP plugin session"); + return session as McpPluginSession; +} diff --git a/App/backend/src/adapters/outbound/plugin-runtime/office-installer.ts b/App/backend/src/adapters/outbound/plugin-runtime/office-installer.ts new file mode 100644 index 000000000..e332e0153 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/office-installer.ts @@ -0,0 +1,228 @@ +/** Host-owned, user-scoped installation of verified portable Office runtimes. */ +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { Readable } from "node:stream"; +import { z } from "zod"; +import * as yauzl from "yauzl"; + +export const OFFICE_TOOLS = ["soffice", "pdfinfo", "pdftoppm", "pdftotext"] as const; +const MAX_ARCHIVE = 1024 * 1024 * 1024; +const MAX_EXPANDED = 4 * 1024 * 1024 * 1024; +const MAX_FILES = 50000; +const sha256 = z.string().regex(/^[a-f0-9]{64}$/); +export function safeRuntimePath(value: string): boolean { + return Boolean(value) && value.length < 1000 && !isAbsolute(value) && !/[\\:\0<>"|?*\x01-\x1f]/.test(value) + && value.split("/").every(p => p && p !== "." && p !== ".." && !/[. ]$/.test(p) + && !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(p)); +} +const filePath = z.string().refine(safeRuntimePath, "Unsafe runtime path"); +export const OfficeRuntimeManifestSchema = z.object({ + format: z.literal("memmy-office-runtime-v1"), + target: z.string().regex(/^(darwin|linux|win32)-(x64|arm64)$/), + version: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,80}$/), + archive: z.object({ url: z.string(), sha256, bytes: z.number().int().positive().max(MAX_ARCHIVE) }).strict(), + tools: z.object({ soffice: filePath, pdfinfo: filePath, pdftoppm: filePath, pdftotext: filePath }).strict(), + files: z.record(filePath, sha256).refine(v => Object.keys(v).length > 0 && Object.keys(v).length <= MAX_FILES) +}).strict(); +export type OfficeRuntimeManifest = z.infer; +export interface InstalledOfficeRuntime { root: string; tools: Record; version: string } +export interface OfficeRuntimeInstallerOptions { + cacheRoot: string; + catalogUrl: string; + fetchFn?: typeof fetch; + /** Primarily for progress displays/diagnostics; never contains signed URLs. */ + onProgress?: (stage: string, bytes?: number, total?: number) => void; +} +export interface OfficeRuntimeInstaller { ensure(platform: NodeJS.Platform, arch: string): Promise } +const inFlight = new Map>(); + +function allowedURL(value: string, base?: string): URL { + const url = new URL(value, base); + if (url.username || url.password || !(url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) throw new Error("Office runtime downloads require HTTPS or loopback HTTP"); + return url; +} +async function hashFile(file: string): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest("hex"); +} +async function verifyRoot(root: string, manifest: OfficeRuntimeManifest): Promise { + // Also reject added DLLs/resources that could change native loader behavior. + let entries = 0; + async function scan(directory: string, prefix = ""): Promise { + for (const item of await readdir(directory, { withFileTypes: true })) { + if (++entries > 100000) throw new Error("Office runtime cache has too many entries"); + const name = prefix + item.name; + if (item.isSymbolicLink()) throw new Error("Office runtime cache contains a symbolic link"); + if (item.isDirectory()) await scan(join(directory, item.name), name + "/"); + else if (!item.isFile() || (!manifest.files[name] && name !== ".memmy-runtime.json")) throw new Error(`Unexpected Office runtime cache entry: ${name}`); + } + } + await scan(root); + for (const [name, expected] of Object.entries(manifest.files)) { + const file = resolve(root, name); + const info = await lstat(file); + if (!info.isFile() || info.isSymbolicLink() || await realpath(file) !== file || await hashFile(file) !== expected) throw new Error(`Office runtime file integrity check failed: ${name}`); + } + const tools: Record = {}; + for (const name of OFFICE_TOOLS) { + const file = manifest.tools[name]; + if (!manifest.files[file]) throw new Error(`Office runtime tool is not covered by the manifest: ${name}`); + tools[name] = resolve(root, file); + if (process.platform !== "win32" && !((await lstat(tools[name]!)).mode & 0o111)) throw new Error(`Office runtime tool is not executable: ${name}`); + } + return { root, tools, version: manifest.version }; +} + +export function createOfficeRuntimeInstaller(options: OfficeRuntimeInstallerOptions): OfficeRuntimeInstaller { + if (!isAbsolute(options.cacheRoot)) throw new Error("Office cache root must be absolute"); + const fetchFn = options.fetchFn ?? fetch; + const base = allowedURL(options.catalogUrl).href; + return { ensure(platform, arch) { + const target = `${platform}-${arch}`; + if (!/^(darwin|linux|win32)-(x64|arm64)$/.test(target)) return Promise.reject(new Error(`Unsupported Office runtime target: ${target}`)); + const key = `${resolve(options.cacheRoot)}\0${target}\0${base}`; + const running = inFlight.get(key); + if (running) return running; + const result = ensure(target).finally(() => { inFlight.delete(key); }); + inFlight.set(key, result); + return result; + } }; + + async function ensure(target: string): Promise { + await mkdir(options.cacheRoot, { recursive: true }); + const cache = await realpath(options.cacheRoot); + const pointer = join(cache, `${target}.json`); + try { + const record = JSON.parse(await readFile(pointer, "utf8")); + const manifest = OfficeRuntimeManifestSchema.parse(record); + if (manifest.target !== target) throw new Error("Cached runtime target mismatch"); + const installed = await verifyRoot(join(cache, target, manifest.archive.sha256), manifest); + options.onProgress?.("cached"); return installed; + } catch { /* Missing/corrupt caches are repaired by a verified reinstall. */ } + const lock = join(cache, `${target}.lock`); + let acquired = false; + for (let attempt = 0; attempt < 1200; attempt++) { + try { + await mkdir(lock); acquired = true; + await writeFile(join(lock, "owner.json"), JSON.stringify({ pid: process.pid }), { flag: "wx" }); + break; + } + catch (error) { + if (acquired) { await rm(lock, { recursive: true, force: true }); throw error; } + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const info = await lstat(lock).catch(() => null); + if (info?.isSymbolicLink()) throw new Error("Unsafe Office installation lock"); + let abandoned = false; + try { + const owner = JSON.parse(await readFile(join(lock, "owner.json"), "utf8")); + if (!Number.isInteger(owner.pid) || owner.pid <= 0) throw new Error("Invalid installation owner"); + try { process.kill(owner.pid, 0); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") abandoned = true; } + } catch { abandoned = Boolean(info && Date.now() - info.mtimeMs > 30000); } + if (abandoned) await rm(lock, { recursive: true, force: true }); + await new Promise(ok => setTimeout(ok, 500)); + } + } + if (!acquired) throw new Error("Office runtime installation is already in progress; retry shortly"); + let temporary: string | undefined; + try { + // Another Host process may have finished while we waited for the lock. + try { + const cached = OfficeRuntimeManifestSchema.parse(JSON.parse(await readFile(pointer, "utf8"))); + if (cached.target === target) return await verifyRoot(join(cache, target, cached.archive.sha256), cached); + } catch { /* fetch a clean manifest */ } + options.onProgress?.("resolving"); + const url = new URL(base.replace(/\{target\}|%7Btarget%7D/gi, target)); + const response = await fetchFn(url, { redirect: "error", signal: AbortSignal.timeout(30000), headers: { accept: "application/json" } }); + if (!response.ok) throw new Error(`Office runtime catalog returned HTTP ${response.status}`); + const chunks: Buffer[] = []; let manifestBytes = 0; + if (!response.body) throw new Error("Office runtime catalog is empty"); + for await (const chunk of Readable.fromWeb(response.body as never)) { + manifestBytes += chunk.length; chunks.push(Buffer.from(chunk)); + if (manifestBytes > 16 * 1024 * 1024) throw new Error("Office runtime manifest exceeds 16 MiB"); + } + const manifest = OfficeRuntimeManifestSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + if (manifest.target !== target) throw new Error("Office runtime platform/architecture mismatch"); + const paths = Object.keys(manifest.files); + if (paths.some(n => n === ".memmy-runtime.json") || new Set(paths.map(n => n.toLowerCase())).size !== paths.length) throw new Error("Duplicate/reserved Office runtime paths"); + const asset = allowedURL(manifest.archive.url, url.href); + temporary = await mkdtemp(join(cache, ".office-install-")); + const archive = join(temporary, "runtime.zip"), content = join(temporary, "content"); + let lastError: unknown; + for (let retry = 0; retry < 3; retry++) { + try { + options.onProgress?.(retry ? "retrying" : "downloading", 0, manifest.archive.bytes); + const download = await fetchFn(asset, { redirect: "error", signal: AbortSignal.timeout(10 * 60 * 1000) }); + if (!download.ok || !download.body) throw new Error(`Office runtime download returned HTTP ${download.status}`); + let received = 0; const digest = createHash("sha256"); + const meter = new Transform({ transform(chunk: Buffer, _encoding, callback) { + received += chunk.length; + if (received > manifest.archive.bytes || received > MAX_ARCHIVE) { callback(new Error("Office runtime download exceeds declared size")); return; } + digest.update(chunk); options.onProgress?.("downloading", received, manifest.archive.bytes); callback(null, chunk); + } }); + await pipeline(Readable.fromWeb(download.body as never), meter, createWriteStream(archive, { flags: "w", mode: 0o600 })); + if (received !== manifest.archive.bytes || digest.digest("hex") !== manifest.archive.sha256) throw new Error("Office runtime archive integrity check failed"); + lastError = undefined; break; + } catch (error) { lastError = error; await rm(archive, { force: true }); } + } + if (lastError) throw lastError; + options.onProgress?.("extracting"); + await mkdir(content); + await extractOfficeRuntime(archive, content, manifest); + await verifyRoot(content, manifest); + await writeFile(join(content, ".memmy-runtime.json"), JSON.stringify(manifest)); + const parent = join(cache, target); await mkdir(parent, { recursive: true }); + if (await realpath(parent) !== parent) throw new Error("Unsafe Office cache directory"); + const destination = join(parent, manifest.archive.sha256); + // The lock excludes readers/installers. Replace only this corrupt content-addressed cache. + await rm(destination, { recursive: true, force: true }); + await rename(content, destination); + const pending = join(cache, `${target}.${randomUUID()}.tmp`); + await writeFile(pending, JSON.stringify(manifest), { flag: "wx" }); await rename(pending, pointer); + options.onProgress?.("installed"); + return await verifyRoot(destination, manifest); + } finally { + if (temporary) await rm(temporary, { recursive: true, force: true }); + await rm(lock, { recursive: true, force: true }); + } + } +} + +export async function extractOfficeRuntime(archive: string, root: string, manifest: OfficeRuntimeManifest): Promise { + const zip = await new Promise((ok, reject) => yauzl.open(archive, { lazyEntries: true, autoClose: true, validateEntrySizes: true }, (error, file) => error ? reject(error) : ok(file!))); + const seen = new Set(); let expanded = 0, files = 0, entries = 0; + await new Promise((ok, reject) => { + let failed = false; + const abort = (error: unknown) => { if (failed) return; failed = true; zip.close(); reject(error); }; + zip.on("error", abort); zip.once("end", () => { if (!failed) ok(); }); + zip.on("entry", (entry: yauzl.Entry) => { + void (async () => { + if (++entries > 100000) throw new Error("Office runtime ZIP has too many entries"); + const name = entry.fileName.replace(/\/$/, ""); + if (!safeRuntimePath(name) || ((entry.externalFileAttributes >>> 16) & 0o170000) === 0o120000) throw new Error("Unsafe Office runtime ZIP entry"); + if (entry.fileName.endsWith("/")) { zip.readEntry(); return; } + if (++files > MAX_FILES || seen.has(name.toLowerCase()) || !manifest.files[name] || entry.uncompressedSize > MAX_EXPANDED - expanded) throw new Error("Unexpected/oversized Office runtime ZIP entry"); + seen.add(name.toLowerCase()); expanded += entry.uncompressedSize; + const target = resolve(root, name); await mkdir(dirname(target), { recursive: true }); + const stream = await new Promise((yes, no) => zip.openReadStream(entry, (error, value) => error ? no(error) : yes(value!))); + let size = 0; const hash = createHash("sha256"); + const check = new Transform({ transform(chunk: Buffer, _encoding, cb) { + size += chunk.length; + if (size > entry.uncompressedSize || size > MAX_EXPANDED) { cb(new Error("Office runtime ZIP expansion limit exceeded")); return; } + hash.update(chunk); cb(null, chunk); + } }); + await pipeline(stream, check, createWriteStream(target, { flags: "wx", mode: 0o600 })); + if (size !== entry.uncompressedSize || hash.digest("hex") !== manifest.files[name]) throw new Error(`Office runtime file digest mismatch: ${name}`); + await chmod(target, ((entry.externalFileAttributes >>> 16) & 0o111) ? 0o755 : 0o644); + zip.readEntry(); + })().catch(abort); + }); + zip.readEntry(); + }); + if (seen.size !== Object.keys(manifest.files).length) throw new Error("Office runtime archive is incomplete"); +} diff --git a/App/backend/src/adapters/outbound/plugin-runtime/registry.ts b/App/backend/src/adapters/outbound/plugin-runtime/registry.ts new file mode 100644 index 000000000..70ba7f1fb --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/registry.ts @@ -0,0 +1,25 @@ +/** Plugin runtime adapter registry. */ +import type { PluginAdapter } from "./types.js"; + +export class PluginAdapterRegistry { + private readonly adapters = new Map(); + + constructor(adapters: PluginAdapter[] = []) { + for (const adapter of adapters) { + if (this.adapters.has(adapter.id)) throw new Error(`Duplicate plugin adapter: ${adapter.id}`); + this.adapters.set(adapter.id, adapter); + } + } + + has(id: string): boolean { + return this.adapters.has(id); + } + + get(id: string): PluginAdapter { + const adapter = this.adapters.get(id); + if (!adapter) throw Object.assign(new Error(`Unsupported plugin adapter: ${id}`), { + code: "plugin_adapter_unsupported" + }); + return adapter; + } +} diff --git a/App/backend/src/adapters/outbound/plugin-runtime/runtime-host.ts b/App/backend/src/adapters/outbound/plugin-runtime/runtime-host.ts new file mode 100644 index 000000000..135c80243 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/runtime-host.ts @@ -0,0 +1,240 @@ +/** Active plugin runtime sessions and capability execution. */ +import { + CapabilityCallSchema, + CapabilityEventSchema, + type CapabilityCall, + type CapabilityEvent +} from "@memmy/local-api-contracts"; +import { Ajv } from "ajv"; +import { PluginAdapterRegistry } from "./registry.js"; +import type { PluginAdapter, PluginRuntimeHost, PluginRuntimeRecord, PluginSession } from "./types.js"; + +const ajv = new Ajv({ allErrors: true, strict: false, addUsedSchema: false }); + +interface ActivePlugin { + plugin: PluginRuntimeRecord; + adapter: PluginAdapter; + session: PluginSession; + calls: Map; +} + +interface ActiveCall { + call: CapabilityCall; + interactions: Map | undefined>; + cancelRequested: boolean; +} + +export function createPluginRuntimeHost(registry: PluginAdapterRegistry): PluginRuntimeHost { + const active = new Map(); + + return { + supports(adapterId) { + return registry.has(adapterId); + }, + + async activate(plugin, secrets) { + if (active.has(plugin.id)) return; + const adapter = registry.get(plugin.manifest.runtime.adapter); + adapter.validate(plugin.manifest.runtime, plugin.rootPath); + const session = await adapter.activate({ + plugin, + config: plugin.config, + secrets, + rootPath: plugin.rootPath + }); + if (session.pluginId !== plugin.id) { + await adapter.deactivate(session).catch(() => undefined); + throw new Error(`Plugin adapter activated a session for ${session.pluginId}`); + } + active.set(plugin.id, { plugin, adapter, session, calls: new Map() }); + }, + + async *invoke(rawCall) { + const call = CapabilityCallSchema.parse(rawCall); + const current = active.get(call.pluginId); + if (!current) { + yield runtimeError("plugin_unavailable", `Plugin is not active: ${call.pluginId}`); + return; + } + const capability = current.plugin.manifest.capabilities.find((candidate) => candidate.id === call.capabilityId); + if (!capability) { + yield runtimeError("plugin_unavailable", `Capability not found: ${call.capabilityId}`); + return; + } + + const inputError = validateValue(capability.inputSchema, call.input); + if (inputError) { + yield runtimeError("plugin_invalid", `Invalid capability input: ${inputError}`); + return; + } + + const activeCall: ActiveCall = { call, interactions: new Map(), cancelRequested: false }; + current.calls.set(call.callId, activeCall); + let terminal = false; + try { + await applyCapabilityControl(current, capability.control, call); + const iterator = current.adapter.invoke(current.session, call)[Symbol.asyncIterator](); + for (;;) { + const item = await nextBeforeDeadline(iterator, call.deadline, () => + current.adapter.cancel?.(current.session, call.callId) + ); + if (item.done) break; + const event = CapabilityEventSchema.parse(item.value); + if (Buffer.byteLength(JSON.stringify(event)) > 10 * 1024 * 1024) { + throw new Error("Plugin event exceeded size limit"); + } + if (event.type === "interaction") { + const pending = current.calls.get(call.callId)!.interactions; + if (pending.has(event.request.interactionId)) { + throw new Error(`Duplicate plugin interaction: ${event.request.interactionId}`); + } + pending.set(event.request.interactionId, event.request.responseSchema); + } + if (event.type === "result") { + const outputError = validateValue(capability.outputSchema, event.output); + if (outputError) { + yield runtimeError("plugin_runtime_error", `Invalid capability output: ${outputError}`); + terminal = true; + break; + } + terminal = true; + } else if (event.type === "error") { + terminal = true; + } + yield event; + if (terminal) break; + } + if (!terminal) yield activeCall.cancelRequested + ? runtimeError("plugin_cancelled", "Plugin call was cancelled") + : runtimeError("plugin_runtime_error", "Plugin ended without a result or error"); + } catch (error) { + yield activeCall.cancelRequested + ? runtimeError("plugin_cancelled", "Plugin call was cancelled") + : runtimeError( + isTimeout(error) ? "plugin_timeout" : "plugin_runtime_error", + error instanceof Error ? error.message : String(error) + ); + } finally { + current.calls.delete(call.callId); + } + }, + + async cancel(pluginId, callId) { + const current = active.get(pluginId); + if (!current) return; + await cancelActiveCall(current, callId); + }, + + async respond(pluginId, callId, interactionId, response) { + const current = active.get(pluginId); + const pending = current?.calls.get(callId)?.interactions; + if (!current || !pending?.has(interactionId)) { + throw Object.assign(new Error("Plugin interaction is not pending"), { code: "plugin_interaction_invalid" }); + } + if (!current.adapter.respond) { + throw Object.assign(new Error("Plugin adapter does not support interactions"), { code: "plugin_interaction_invalid" }); + } + const schema = pending.get(interactionId); + if (schema) { + const error = validateValue(schema, response); + if (error) throw Object.assign(new Error(`Invalid plugin interaction response: ${error}`), { + code: "plugin_interaction_invalid" + }); + } + await current.adapter.respond(current.session, callId, interactionId, response); + pending.delete(interactionId); + }, + + async deactivate(pluginId) { + const current = active.get(pluginId); + if (!current) return; + active.delete(pluginId); + await Promise.all([...current.calls.keys()].map((callId) => cancelActiveCall(current, callId))); + await current.adapter.deactivate(current.session); + } + }; +} + +async function applyCapabilityControl( + current: ActivePlugin, + control: PluginRuntimeRecord["manifest"]["capabilities"][number]["control"], + call: CapabilityCall +): Promise { + if (control?.action !== "cancel") return; + const input = asRecord(call.input); + const scope = input[control.scopeInput]; + if (scope === "task") { + const taskId = input[control.taskIdInput]; + if (typeof taskId !== "string" || !taskId) return; + const targets = [...current.calls.values()] + .filter((candidate) => candidate.call.callId !== call.callId) + .filter((candidate) => asRecord(candidate.call.input)[control.taskIdInput] === taskId) + .map((candidate) => candidate.call.callId); + await Promise.all(targets.map((target) => cancelActiveCall(current, target))); + return; + } + const target = input[control.runIdInput]; + if (typeof target === "string" && target && target !== call.callId) { + const requestedTaskId = input[control.taskIdInput]; + const targetCall = current.calls.get(target)?.call; + const targetTaskId = targetCall ? asRecord(targetCall.input)[control.taskIdInput] : undefined; + if (typeof requestedTaskId === "string" && requestedTaskId && targetTaskId === requestedTaskId) { + await cancelActiveCall(current, target); + } + } +} + +async function cancelActiveCall(current: ActivePlugin, callId: string): Promise { + const target = current.calls.get(callId); + if (!target) return false; + target.cancelRequested = true; + await current.adapter.cancel?.(current.session, callId); + return true; +} + +function validateValue(schema: Record, value: unknown): string | null { + const validate = ajv.compile(schema); + return validate(value) ? null : (validate.errors?.[0]?.message ?? "schema validation failed"); +} + +async function nextBeforeDeadline( + iterator: AsyncIterator, + deadline: string | undefined, + cancel: () => Promise | undefined +): Promise> { + if (!deadline) return iterator.next(); + const remaining = Date.parse(deadline) - Date.now(); + if (remaining <= 0) { + await cancel(); + throw timeoutError(); + } + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + iterator.next(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + void Promise.resolve(cancel()).finally(() => reject(timeoutError())); + }, Math.min(remaining, 2_147_483_647)); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function timeoutError(): Error { + return Object.assign(new Error("Plugin call timed out"), { code: "plugin_timeout" }); +} + +function isTimeout(error: unknown): boolean { + return (error as { code?: unknown })?.code === "plugin_timeout"; +} + +function runtimeError(code: string, message: string): CapabilityEvent { + return { type: "error", code, message, retryable: code === "plugin_timeout" || code === "plugin_runtime_error" }; +} + +function asRecord(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} diff --git a/App/backend/src/adapters/outbound/plugin-runtime/shared.ts b/App/backend/src/adapters/outbound/plugin-runtime/shared.ts new file mode 100644 index 000000000..5c927657d --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/shared.ts @@ -0,0 +1,41 @@ +import type { CapabilityEvent } from "@memmy/local-api-contracts"; +import type { PluginRuntimeContext } from "./types.js"; + +const BLOCKED_STATIC_HEADERS = new Set(["authorization", "cookie", "host", "proxy-authorization"]); + +export function assertSafeStaticHeaders(headers: Readonly>, adapter: string): void { + const blocked = Object.keys(headers).find((name) => BLOCKED_STATIC_HEADERS.has(name.toLowerCase())); + if (blocked) throw new Error(`Sensitive ${adapter} header must use secretHeaders: ${blocked}`); +} + +export function assertNetworkPermission(context: PluginRuntimeContext, hostname: string): void { + const allowed = context.plugin.manifest.permissions.some((permission) => + permission.type === "network" && permission.hosts.includes(hostname) + ); + if (!allowed) throw Object.assign(new Error(`Plugin network permission does not allow ${hostname}`), { + code: "plugin_permission_denied" + }); +} + +export function resolveSecretHeaders( + headers: Readonly>, + secretHeaders: Readonly>, + secrets: Readonly>, + adapter: string +): Record { + const resolved = { ...headers }; + for (const [header, key] of Object.entries(secretHeaders)) { + const value = secrets[key]; + if (!value) throw new Error(`Missing plugin secret for ${adapter} header ${header}`); + resolved[header] = value; + } + return resolved; +} + +export function callTimeoutMs(configured: number, deadline: string | undefined): number { + return deadline ? Math.max(1, Math.min(configured, Date.parse(deadline) - Date.now())) : configured; +} + +export function isCapabilityEvent(value: unknown): value is CapabilityEvent { + return Boolean(value && typeof value === "object" && "type" in value); +} diff --git a/App/backend/src/adapters/outbound/plugin-runtime/tests/command-adapter.test.ts b/App/backend/src/adapters/outbound/plugin-runtime/tests/command-adapter.test.ts new file mode 100644 index 000000000..3e735a383 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/tests/command-adapter.test.ts @@ -0,0 +1,598 @@ +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync, spawn } from "node:child_process"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CapabilityEvent } from "@memmy/local-api-contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildPluginSandboxLaunch, + createCommandPluginAdapter, + resolveCommandRuntimeDependencies, + resolvePluginEnvironment +} from "../command-adapter.js"; +import type { PluginRuntimeContext } from "../types.js"; + +let root: string | undefined; + +afterEach(() => { + if (root) rmSync(root, { recursive: true, force: true }); + root = undefined; + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function context(outputMode: "json" | "ndjson" = "json"): PluginRuntimeContext { + root = mkdtempSync(join(tmpdir(), "memmy-command-plugin-")); + mkdirSync(join(root, "runtime")); + writeFileSync(join(root, "runtime/plugin"), "test"); + chmodSync(join(root, "runtime/plugin"), 0o755); + const now = new Date().toISOString(); + return { + plugin: { + id: "com.example.command", + version: "1.0.0", + manifest: { + apiVersion: "memmy/v1", + id: "com.example.command", + name: "Command", + version: "1.0.0", + runtime: { adapter: "command", config: { command: "runtime/plugin", outputMode } }, + capabilities: [{ + id: "run", + name: "Run", + description: "Run command", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "request" + }], + permissions: [] + }, + state: "active", + approvedPermissions: [], + config: {}, + artifactHash: "hash", + rootPath: root, + lastError: null, + createdAt: now, + updatedAt: now + }, + config: {}, + secrets: {}, + rootPath: root + }; +} + +async function collect(iterable: AsyncIterable): Promise { + const events: CapabilityEvent[] = []; + for await (const event of iterable) events.push(event); + return events; +} + +describe("CommandPluginAdapter", () => { + it.runIf(process.platform === "darwin" && process.env.CODEX_SANDBOX !== "seatbelt")( + "allows private plugin IPC while denying sockets outside plugin-data and loopback TCP", async () => { + const data = realpathSync(mkdtempSync("/private/tmp/memmy-ipc-")); + const unix = createServer(socket => socket.end()); + const tcp = createServer(socket => socket.end()); + await new Promise(ok => unix.listen(join(data, "host.sock"), ok)); + await new Promise(ok => tcp.listen(0, "127.0.0.1", ok)); + try { + const pluginContext = context(); + const permission = { type: "host-service" as const, services: ["plugin-data"] }; + pluginContext.plugin.manifest.permissions = [permission]; + pluginContext.plugin.approvedPermissions = [permission]; + execFileSync("/usr/bin/clang", ["-x", "c", "-o", join(root!, "runtime/plugin"), "-"], { input: ` + #include + #include + #include + #include + #include + #include + int main(int argc,char **argv) { + chdir(argv[1]); struct sockaddr_un own={0}; own.sun_family=AF_UNIX; strcpy(own.sun_path,"inside.sock"); + int a=socket(AF_UNIX,SOCK_STREAM,0); int bound=bind(a,(struct sockaddr*)&own,sizeof(own))==0; + struct sockaddr_un other={0}; other.sun_family=AF_UNIX; strcpy(other.sun_path,argv[2]); + int b=socket(AF_UNIX,SOCK_STREAM,0); int external=connect(b,(struct sockaddr*)&other,sizeof(other))==0; + struct sockaddr_in remote={0}; remote.sin_family=AF_INET; remote.sin_addr.s_addr=htonl(0x7f000001); remote.sin_port=htons(${(tcp.address() as { port: number }).port}); + int c=socket(AF_INET,SOCK_STREAM,0); int network=connect(c,(struct sockaddr*)&remote,sizeof(remote))==0; + printf("{\\"bound\\":%s,\\"external\\":%s,\\"network\\":%s}\\n",bound?"true":"false",external?"true":"false",network?"true":"false"); + close(a);close(b);close(c);return 0; + }` }); + pluginContext.plugin.manifest.runtime.config = { command: "runtime/plugin", args: [join(data, pluginContext.plugin.id), join(data, "host.sock")] }; + const adapter = createCommandPluginAdapter({ pluginDataRoot: data }); + const session = await adapter.activate(pluginContext); + try { + expect(await collect(adapter.invoke(session, {callId:"ipc",pluginId:pluginContext.plugin.id,capabilityId:"run",conversationId:"test",input:{}}))) + .toEqual([{ type: "result", output: { bound: true, external: false, network: false } }]); + } finally { await adapter.deactivate(session); } + } finally { + await Promise.all([new Promise(ok => unix.close(() => ok())), new Promise(ok => tcp.close(() => ok()))]); + rmSync(data, { recursive: true, force: true }); + } + }, 30000 + ); + it.runIf(process.platform === "darwin" && process.env.CODEX_SANDBOX !== "seatbelt")( + "executes an artifact command through the macOS sandbox", + async () => { + const pluginContext = context(); + writeFileSync(join(root!, "runtime/plugin"), "#!/bin/sh\nprintf '{\"ok\":true}\\n'\n"); + chmodSync(join(root!, "runtime/plugin"), 0o755); + const adapter = createCommandPluginAdapter(); + const session = await adapter.activate(pluginContext); + expect(await collect(adapter.invoke(session, { + callId: "call-1", + pluginId: pluginContext.plugin.id, + capabilityId: "run", + conversationId: "conversation-1", + input: {} + }))).toEqual([{ type: "result", output: { ok: true } }]); + } + ); + + it("maps a sandboxed command JSON response", async () => { + let childEnvironment: Record | undefined; + const adapter = createCommandPluginAdapter({ + spawnFn: ((command, args, options) => { + childEnvironment = options?.env as Record; + return spawn(command, args, options as Parameters[2]) as ReturnType; + }) as typeof spawn, + buildLaunch: async (_context, config) => ({ + command: process.execPath, + args: ["-e", "let body=''; process.stdin.on('data', chunk => body += chunk); process.stdin.on('end', () => { const call=JSON.parse(body); console.log(JSON.stringify({pluginId:call.pluginId,input:call.input})); })", ...config.args], + cwd: root! + }) + }); + const pluginContext = context(); + pluginContext.plugin.manifest.runtime.config = { + ...pluginContext.plugin.manifest.runtime.config, + interpreter: "node" + }; + const session = await adapter.activate(pluginContext); + expect(await collect(adapter.invoke(session, { + callId: "call-1", + pluginId: pluginContext.plugin.id, + capabilityId: "run", + conversationId: "conversation-1", + input: { topic: "memory" } + }))).toEqual([{ type: "result", output: { pluginId: "com.example.command", input: { topic: "memory" } } }]); + expect(childEnvironment?.ELECTRON_RUN_AS_NODE).toBe("1"); + }); + + it("streams NDJSON events", async () => { + const adapter = createCommandPluginAdapter({ + buildLaunch: async (_context, config) => ({ + command: process.execPath, + args: ["-e", `console.log(JSON.stringify({type:'progress',current:1,total:1})); console.log(JSON.stringify({type:'result',output:{ok:true}}))`, ...config.args], + cwd: root! + }) + }); + const pluginContext = context("ndjson"); + const session = await adapter.activate(pluginContext); + expect(await collect(adapter.invoke(session, { + callId: "call-1", + pluginId: pluginContext.plugin.id, + capabilityId: "run", + conversationId: "conversation-1", + input: {} + }))).toEqual([ + { type: "progress", current: 1, total: 1 }, + { type: "result", output: { ok: true } } + ]); + }); + + it("does not consume the command timeout while an interaction waits for the user", async () => { + const adapter = createCommandPluginAdapter({ + buildLaunch: async (_context, config) => ({ + command: process.execPath, + args: ["-e", ` + const rl=require('node:readline').createInterface({input:process.stdin}); + let first=true; + rl.on('line', line => { + const value=JSON.parse(line); + if(first){ + first=false; + console.log(JSON.stringify({type:'interaction',request:{interactionId:'review-card',type:'custom',payload:{}}})); + } else { + console.log(JSON.stringify({type:'result',output:{response:value.response}})); + process.exit(0); + } + }); + `, ...config.args], + cwd: root! + }) + }); + const pluginContext = context("ndjson"); + pluginContext.plugin.manifest.runtime.config = { + ...pluginContext.plugin.manifest.runtime.config, + interactive: true, + timeoutMs: 2000 + }; + const session = await adapter.activate(pluginContext); + const iterator = adapter.invoke(session, { + callId: "call-interaction", + pluginId: pluginContext.plugin.id, + capabilityId: "run", + conversationId: "conversation-1", + input: {} + })[Symbol.asyncIterator](); + + expect((await iterator.next()).value).toMatchObject({ + type: "interaction", + request: { interactionId: "review-card" } + }); + await new Promise((resolve) => setTimeout(resolve, 2100)); + await adapter.respond?.(session, "call-interaction", "review-card", { confirmed: true }); + expect((await iterator.next()).value).toEqual({ type: "result", output: { response: { confirmed: true } } }); + expect((await iterator.next()).done).toBe(true); + }); + + it("brokers approved Host-service requests over the private NDJSON channel", async () => { + const adapter = createCommandPluginAdapter({ + hostServices: { invoke: async (call) => ({ content: `model:${call.conversationId}:${call.modelPreset}` }) }, + buildLaunch: async (_context, config) => ({ + command: process.execPath, + args: ["-e", ` + const rl=require('node:readline').createInterface({input:process.stdin}); + let first=true; + rl.on('line', line => { + const value=JSON.parse(line); + if(first){ first=false; console.log(JSON.stringify({type:'host-service-request',requestId:'model-1',service:'model-inference',input:{messages:[{role:'user',content:'hello'}]}})); } + else { console.log(JSON.stringify({type:'result',output:{host:value.response}})); process.exit(0); } + }); + `, ...config.args], + cwd: root! + }) + }); + const pluginContext = context("ndjson"); + pluginContext.plugin.manifest.runtime.config = { ...pluginContext.plugin.manifest.runtime.config, interactive: true }; + const permission = { type: "host-service" as const, services: ["model-inference"] }; + pluginContext.plugin.manifest.permissions = [permission]; + pluginContext.plugin.approvedPermissions = [permission]; + const session = await adapter.activate(pluginContext); + expect(await collect(adapter.invoke(session, { + callId: "call-1", pluginId: pluginContext.plugin.id, capabilityId: "run", conversationId: "conversation-1", + modelPreset: "account-deepseek-v4.1-flash", input: {} + }))).toEqual([{ type: "result", output: { host: { content: "model:conversation-1:account-deepseek-v4.1-flash" } } }]); + }); + + it("brokers concurrent Host-service requests instead of serialising them", async () => { + // A capability that fans out — the legal diagnosis judges sections in + // parallel — gains nothing if the Host answers one request at a time. + let inFlight = 0; + let peak = 0; + const adapter = createCommandPluginAdapter({ + hostServices: { + invoke: async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 30)); + inFlight -= 1; + return { content: "ok" }; + } + }, + buildLaunch: async (_context, config) => ({ + command: process.execPath, + args: ["-e", ` + const rl=require('node:readline').createInterface({input:process.stdin}); + let first=true, answered=0; + const ids=['a','b','c','d']; + rl.on('line', line => { + const value=JSON.parse(line); + if(first){ + first=false; + for(const id of ids) console.log(JSON.stringify({type:'host-service-request',requestId:id,service:'model-inference',input:{}})); + } else if(++answered===ids.length){ + console.log(JSON.stringify({type:'result',output:{answered}})); + process.exit(0); + } + }); + `, ...config.args], + cwd: root! + }) + }); + const pluginContext = context("ndjson"); + pluginContext.plugin.manifest.runtime.config = { ...pluginContext.plugin.manifest.runtime.config, interactive: true }; + const permission = { type: "host-service" as const, services: ["model-inference"] }; + pluginContext.plugin.manifest.permissions = [permission]; + pluginContext.plugin.approvedPermissions = [permission]; + const session = await adapter.activate(pluginContext); + expect(await collect(adapter.invoke(session, { + callId: "call-parallel", pluginId: pluginContext.plugin.id, capabilityId: "run", conversationId: "conversation-1", input: {} + }))).toEqual([{ type: "result", output: { answered: 4 } }]); + // Serial dispatch would peak at one. + expect(peak).toBeGreaterThan(1); + }); + + it("aborts in-flight Host services when a command-plugin run is cancelled", async () => { + const started = Promise.withResolvers(); + let hostSignal: AbortSignal | undefined; + const adapter = createCommandPluginAdapter({ + hostServices: { + invoke: async (call) => { + hostSignal = call.signal; + started.resolve(); + return await new Promise((_resolve, reject) => call.signal?.addEventListener("abort", () => reject(new Error("cancelled")), { once: true })); + } + }, + buildLaunch: async (_context, config) => ({ + command: process.execPath, + args: ["-e", ` + const rl=require('node:readline').createInterface({input:process.stdin}); let first=true; + rl.on('line', () => { if(first){ first=false; console.log(JSON.stringify({type:'host-service-request',requestId:'model-1',service:'model-inference',input:{}})); } }); + `, ...config.args], + cwd: root! + }) + }); + const pluginContext = context("ndjson"); + pluginContext.plugin.manifest.runtime.config = { ...pluginContext.plugin.manifest.runtime.config, interactive: true }; + const permission = { type: "host-service" as const, services: ["model-inference"] }; + pluginContext.plugin.manifest.permissions = [permission]; + pluginContext.plugin.approvedPermissions = [permission]; + const session = await adapter.activate(pluginContext); + const pending = collect(adapter.invoke(session, { + callId: "call-cancel", pluginId: pluginContext.plugin.id, capabilityId: "run", conversationId: "conversation-1", input: {} + })); + await started.promise; + await adapter.cancel?.(session, "call-cancel"); + expect(hostSignal?.aborted).toBe(true); + await expect(pending).rejects.toBeDefined(); + }); + + it("reports a closed response pipe as a call failure without an uncaught stream error", async () => { + const invoke = vi.fn(async () => ({ ok: true })); + const adapter = createCommandPluginAdapter({ + hostServices: { invoke }, + buildLaunch: async () => ({ + command: process.execPath, + args: ["-e", ` + const fs=require('node:fs'); + fs.readSync(0,Buffer.alloc(65536),0,65536,null); + fs.closeSync(0); + // Keep descriptor zero occupied so libuv cannot reuse it for an + // internal handle (some Node 24 builds assert when closing fd 0). + fs.openSync(process.platform==='win32'?'NUL':'/dev/null','r'); + console.log(JSON.stringify({type:'host-service-request',requestId:'closed-pipe',service:'model-inference',input:{}})); + setInterval(() => {}, 1000); + `], cwd: root! + }) + }); + const pluginContext = context("ndjson"); + pluginContext.plugin.manifest.runtime.config = { ...pluginContext.plugin.manifest.runtime.config, interactive: true }; + const permission = { type: "host-service" as const, services: ["model-inference"] }; + pluginContext.plugin.manifest.permissions = [permission]; + pluginContext.plugin.approvedPermissions = [permission]; + const session = await adapter.activate(pluginContext); + try { + await expect(collect(adapter.invoke(session, { + callId: "call-closed-pipe", pluginId: pluginContext.plugin.id, capabilityId: "run", conversationId: "conversation-1", input: {} + }))).rejects.toMatchObject({ code: "EPIPE" }); + expect(invoke).toHaveBeenCalledOnce(); + } finally { await adapter.deactivate(session); } + }); + + it("denies unapproved Host-service requests without invoking the service", async () => { + const invoke = vi.fn(); + const adapter = createCommandPluginAdapter({ + hostServices: { invoke }, + buildLaunch: async () => ({ + command: process.execPath, + args: ["-e", ` + const rl=require('node:readline').createInterface({input:process.stdin}); let first=true; + rl.on('line', line => { const value=JSON.parse(line); if(first){ first=false; console.log(JSON.stringify({type:'host-service-request',requestId:'model-1',service:'model-inference',input:{}})); } else { console.log(JSON.stringify({type:'result',output:value.error})); process.exit(0); } }); + `], cwd: root! + }) + }); + const pluginContext = context("ndjson"); + pluginContext.plugin.manifest.runtime.config = { ...pluginContext.plugin.manifest.runtime.config, interactive: true }; + const session = await adapter.activate(pluginContext); + expect(await collect(adapter.invoke(session, { callId: "call-1", pluginId: pluginContext.plugin.id, capabilityId: "run", conversationId: "conversation-1", input: {} }))).toEqual([ + { type: "result", output: { code: "plugin_permission_denied", message: "Host service permission was not approved: model-inference", retryable: false } } + ]); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("allows only command-plugin network hosts approved by the host policy", async () => { + const pluginContext = context(); + pluginContext.plugin.manifest.permissions = [{ type: "network", hosts: ["example.com"] }]; + await expect(createCommandPluginAdapter().activate(pluginContext)).rejects.toThrow(/not in the host allowlist/); + let networkEnabled = false; + const adapter = createCommandPluginAdapter({ + allowedNetworkHosts: ["example.com"], + buildLaunch: async (_context, _config, enabled) => { + networkEnabled = enabled; + return { command: process.execPath, args: [], cwd: root! }; + } + }); + await expect(adapter.activate(pluginContext)).resolves.toBeDefined(); + expect(networkEnabled).toBe(true); + }); + + it("rejects commands outside the artifact", async () => { + const pluginContext = context(); + pluginContext.plugin.manifest.permissions = []; + pluginContext.plugin.manifest.runtime.config = { command: "../outside" }; + await expect(createCommandPluginAdapter().activate(pluginContext)).rejects.toThrow(/relative/); + }); + + it("runs non-executable JavaScript artifacts with the host Node interpreter", async () => { + const pluginContext = context(); + chmodSync(join(root!, "runtime/plugin"), 0o644); + const launch = await buildPluginSandboxLaunch(pluginContext, { + command: "runtime/plugin", + interpreter: "node", + args: ["--flag"], + cwd: "." + }, "darwin"); + const separator = launch.args.indexOf("--"); + expect(launch.args[separator + 1]).toBe(realpathSync(process.execPath)); + expect(launch.args.slice(separator + 2)).toEqual([realpathSync(join(root!, "runtime/plugin")), "--flag"]); + }); + + it("prepends only Host-resolved runtime paths to the plugin environment", () => { + expect(resolvePluginEnvironment({ TEXMFHOME: "/plugin/path" }, {}, {}, ["/trusted/tex/bin"], { TEXMFHOME: "/trusted/texmf" })).toMatchObject({ + PATH: "/trusted/tex/bin:/usr/bin:/bin", + LANG: "C.UTF-8", + TEXMFHOME: "/trusted/texmf" + }); + }); + + it("grants declared TeX Live dependencies read and executable sandbox access on macOS", async () => { + const dependency = await resolveCommandRuntimeDependencies(["texlive"], "darwin"); + if (dependency.pathEntries.length === 0) return; + const pluginContext = context(); + const launch = await buildPluginSandboxLaunch( + pluginContext, + { command: "runtime/plugin", interpreter: "node", args: [], cwd: "." }, + "darwin", + false, + [], + undefined, + dependency.readRoots, + dependency.executableRoots + ); + const profile = launch.args[1] ?? ""; + expect(dependency.pathEntries[0]).toMatch(/texlive/u); + expect(dependency.environment.TEXMFHOME).toMatch(/texmf/u); + expect(profile).toContain(`(allow file-read*`); + expect(profile).toContain(`(subpath ${JSON.stringify(dependency.readRoots[0])})`); + expect(profile).toContain(`(subpath ${JSON.stringify(dependency.executableRoots[0])})`); + }); + + it("builds a trusted Windows launcher plan with explicit roots and exact arguments", async () => { + const pluginContext = context(); + const launch = await buildPluginSandboxLaunch(pluginContext, { command: "runtime/plugin", interpreter: "node", args: ["a b", "中文", 'quote"value'], cwd: "." }, "win32"); + expect(launch.command).toMatch(/powershell\.exe$/); + const plan = JSON.parse(Buffer.from(launch.args[launch.args.indexOf("-Plan") + 1]!, "base64").toString("utf8")); + expect(plan.command).toBe(realpathSync(process.execPath)); + expect(plan.args).toEqual(["--preserve-symlinks", "--preserve-symlinks-main", realpathSync(join(root!, "runtime/plugin")), "a b", "中文", 'quote"value']); + expect(plan.readRoots).toContain(realpathSync(root!)); expect(plan.writeRoots).toEqual([]); + expect(plan.profile).toMatch(/^memmy\.plugin\.[a-f0-9]{32}$/); + await expect(buildPluginSandboxLaunch(pluginContext, { command: "runtime/plugin", interpreter: "node", args: [], cwd: "." }, "win32", true)).rejects.toThrow(/no network/); + }); + + it("resolves the default runtime route against the registry origin, including bases with /api", async () => { + const pluginContext = context(); + vi.stubEnv("MEMMY_PLUGIN_REGISTRY_URL", "http://127.0.0.1:19391/api"); + vi.stubEnv("MEMMY_OFFICE_RUNTIME_PREFER_MANAGED", "1"); + vi.stubEnv("MEMMY_OFFICE_RUNTIME_CATALOG_URL", ""); + delete process.env.MEMMY_OFFICE_RUNTIME_CATALOG_URL; + const fetchFn = vi.fn(async (_input: unknown) => new Response("not provisioned", { status: 404 })); vi.stubGlobal("fetch", fetchFn); + pluginContext.plugin.manifest.runtime.config = { ...pluginContext.plugin.manifest.runtime.config, runtimeDependencies: ["office"] }; + await expect(createCommandPluginAdapter({ platform: "win32", pluginDataRoot: join(root!, "plugin-data") }).activate(pluginContext)).rejects.toThrow(/HTTP 404/); + expect(String(fetchFn.mock.calls[0]?.[0])).toBe(`http://127.0.0.1:19391/api/v1/plugins/office/runtimes/win32-${process.arch}`); + }); + + it("keeps plugin CLR/PowerShell variables out of the privileged Windows launcher", async () => { + const pluginContext = context(); + pluginContext.plugin.manifest.runtime.config = { ...pluginContext.plugin.manifest.runtime.config, interpreter: "node", env: { COR_ENABLE_PROFILING: "1", COR_PROFILER_PATH: "untrusted.dll", PSModulePath: "untrusted-modules", SystemRoot: "untrusted-root", LOCALAPPDATA: "untrusted-profile" } }; + let launcherEnv: Record = {}; + const adapter = createCommandPluginAdapter({ platform: "win32", spawnFn: ((command, _args, options) => { + expect(command).toMatch(/powershell\.exe$/); launcherEnv = options?.env as Record; + return spawn(process.execPath, ["-e", "console.log('{}')"], options as Parameters[2]); + }) as typeof spawn }); + const session = await adapter.activate(pluginContext); + await collect(adapter.invoke(session, { callId: "env-test", pluginId: pluginContext.plugin.id, capabilityId: "run", conversationId: "test", input: {} })); + expect(launcherEnv.COR_ENABLE_PROFILING).toBeUndefined(); expect(launcherEnv.COR_PROFILER_PATH).toBeUndefined(); + expect(launcherEnv.PSModulePath).toBe(`${launcherEnv.SystemRoot}\\System32\\WindowsPowerShell\\v1.0\\Modules`); + expect(launcherEnv.SystemRoot).not.toBe("untrusted-root"); + expect(launcherEnv.LOCALAPPDATA).toBeUndefined(); + const isolatedEnv = JSON.parse(Buffer.from(launcherEnv.MEMMY_SANDBOX_CHILD_ENV!, "base64").toString("utf8")); + expect(isolatedEnv.COR_PROFILER_PATH).toBe("untrusted.dll"); + expect(isolatedEnv.LOCALAPPDATA).toBe("untrusted-profile"); + }); + + it("injects only the Host-installed portable Office tools into the runtime", async () => { + context(); vi.stubEnv("MEMMY_OFFICE_RUNTIME_PREFER_MANAGED", "1"); + const tools = Object.fromEntries(["soffice", "pdfinfo", "pdftoppm", "pdftotext"].map(name => [name, join(root!, "runtime", `${name}.exe`)])); + const ensure = vi.fn(async () => ({ root: root!, version: "test-1", tools })); + const dependencies = await resolveCommandRuntimeDependencies(["office"], "win32", { ensure }); + expect(ensure).toHaveBeenCalledWith("win32", process.arch); + expect(JSON.parse(dependencies.environment.MEMMY_OFFICE_TOOLS!)).toEqual(tools); + expect(dependencies.readRoots).toEqual([root]); expect(dependencies.executableRoots).toEqual([root]); + }); + + describe("sandbox: none", () => { + /** + * Activates a `sandbox: "none"` plugin and reports the command that was + * actually spawned, which is how the sandbox decision is observable: + * the sandboxed path launches the sandbox binary, the exempt path + * launches the plugin itself. + */ + async function spawnedCommandFor(options: { + entitlement: string | null; + granted: string[]; + platform?: NodeJS.Platform; + }): Promise { + let spawnedCommand = ""; + const adapter = createCommandPluginAdapter({ + platform: options.platform ?? "darwin", + isEntitlementGranted: (entitlement) => options.granted.includes(entitlement), + spawnFn: ((command, _args, spawnOptions) => { + spawnedCommand = command; + // Runs something harmless instead, so the assertion is about which + // launch was chosen rather than about the sandbox working here. + return spawn(process.execPath, ["-e", "console.log('{}')"], spawnOptions as Parameters[2]); + }) as typeof spawn + }); + const pluginContext = context(); + pluginContext.plugin.manifest.runtime.config = { + ...pluginContext.plugin.manifest.runtime.config, + sandbox: "none" + }; + if (options.entitlement) pluginContext.plugin.manifest.requiredEntitlement = options.entitlement; + const session = await adapter.activate(pluginContext); + await collect(adapter.invoke(session, { + callId: "call-1", + pluginId: pluginContext.plugin.id, + capabilityId: "run", + conversationId: "conversation-1", + input: {} + })); + return spawnedCommand; + } + + it("runs a plugin unsandboxed when the account holds its declared entitlement", async () => { + const command = await spawnedCommandFor({ + entitlement: "plugin:legal-labor", + granted: ["plugin:legal-labor"] + }); + + expect(command).not.toMatch(/sandbox-exec|bwrap/u); + expect(command).toContain("runtime/plugin"); + }); + + it("keeps the sandbox on when the account does not hold the entitlement", async () => { + // A third-party plugin can copy `sandbox: "none"` and any entitlement + // name into its manifest, so the grant has to come from the account. + const command = await spawnedCommandFor({ + entitlement: "plugin:legal-labor", + granted: [] + }); + + expect(command).toMatch(/sandbox-exec$/u); + }); + + it("keeps the sandbox on when the manifest declares no entitlement at all", async () => { + const command = await spawnedCommandFor({ entitlement: null, granted: ["plugin:legal-labor"] }); + + expect(command).toMatch(/sandbox-exec$/u); + }); + + it.each(["plugin:legal-labor", "plugin:literature-review"])("runs unsandboxed on Windows with %s", async (entitlement) => { + // Existing entitled exemptions remain compatible with Windows. + const command = await spawnedCommandFor({ + entitlement, + granted: [entitlement], + platform: "win32" + }); + + expect(command).not.toMatch(/sandbox-exec|bwrap/u); + }); + + it.each([null, "plugin:literature-review"])("uses AppContainer on Windows without a grant for %s", async (entitlement) => { + const command = await spawnedCommandFor({ entitlement, granted: [], platform: "win32" }); + expect(command).toMatch(/powershell\.exe$/u); + }); + }); +}); diff --git a/App/backend/src/adapters/outbound/plugin-runtime/tests/http-adapter.test.ts b/App/backend/src/adapters/outbound/plugin-runtime/tests/http-adapter.test.ts new file mode 100644 index 000000000..8e2aafa8b --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/tests/http-adapter.test.ts @@ -0,0 +1,119 @@ +import type { CapabilityEvent } from "@memmy/local-api-contracts"; +import { describe, expect, it, vi } from "vitest"; +import { createHttpPluginAdapter } from "../http-adapter.js"; +import type { PluginRuntimeContext } from "../types.js"; + +function context(): PluginRuntimeContext { + const now = new Date().toISOString(); + return { + plugin: { + id: "com.example.review", + version: "1.0.0", + manifest: { + apiVersion: "memmy/v1", + id: "com.example.review", + name: "Review", + version: "1.0.0", + runtime: { + adapter: "http", + config: { + baseUrl: "https://plugin.example/api/", + invokePath: "capabilities/{capabilityId}/invoke", + secretHeaders: { authorization: "api-key" } + } + }, + capabilities: [{ + id: "run", + name: "Run", + description: "Run a review", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "job" + }], + permissions: [ + { type: "network", hosts: ["plugin.example"] }, + { type: "secret", keys: ["api-key"] } + ] + }, + state: "active", + approvedPermissions: [], + config: { database: "crossref" }, + artifactHash: null, + rootPath: null, + lastError: null, + createdAt: now, + updatedAt: now + }, + config: { database: "crossref" }, + secrets: { "api-key": "Bearer secret" }, + rootPath: null + }; +} + +async function collect(iterable: AsyncIterable): Promise { + const events: CapabilityEvent[] = []; + for await (const event of iterable) events.push(event); + return events; +} + +describe("HttpPluginAdapter", () => { + it("maps JSON responses to a result and keeps secrets in headers", async () => { + const fetchFn = vi.fn(async () => new Response(JSON.stringify({ summary: "done" }), { + headers: { "content-type": "application/json" } + })); + const adapter = createHttpPluginAdapter({ fetchFn: fetchFn as typeof fetch }); + const session = await adapter.activate(context()); + expect(await collect(adapter.invoke(session, { + callId: "call-1", + pluginId: context().plugin.id, + capabilityId: "run", + conversationId: "conversation-1", + input: { topic: "memory" } + }))).toEqual([{ type: "result", output: { summary: "done" } }]); + + const [url, init] = fetchFn.mock.calls[0]!; + expect(String(url)).toBe("https://plugin.example/api/capabilities/run/invoke"); + expect((init?.headers as Record).authorization).toBe("Bearer secret"); + expect(String(init?.body)).not.toContain("Bearer secret"); + }); + + it("streams NDJSON capability events", async () => { + const body = [ + JSON.stringify({ type: "progress", current: 1, total: 2 }), + JSON.stringify({ type: "result", output: { summary: "done" } }) + ].join("\n"); + const adapter = createHttpPluginAdapter({ + fetchFn: vi.fn(async () => new Response(body, { headers: { "content-type": "application/x-ndjson" } })) as typeof fetch + }); + const session = await adapter.activate(context()); + expect(await collect(adapter.invoke(session, { + callId: "call-1", + pluginId: context().plugin.id, + capabilityId: "run", + conversationId: "conversation-1", + input: {} + }))).toEqual([ + { type: "progress", current: 1, total: 2 }, + { type: "result", output: { summary: "done" } } + ]); + }); + + it("rejects hosts outside declared network permissions", async () => { + const denied = context(); + denied.plugin.manifest.permissions = [{ type: "network", hosts: ["other.example"] }]; + await expect(createHttpPluginAdapter().activate(denied)).rejects.toThrow(/does not allow/); + }); + + it("posts interaction responses to the plugin", async () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 204 })); + const adapter = createHttpPluginAdapter({ fetchFn: fetchFn as typeof fetch }); + const session = await adapter.activate(context()); + await adapter.respond?.(session, "call-1", "question-1", { selected: ["memory"] }); + expect(String(fetchFn.mock.calls[0]?.[0])).toBe("https://plugin.example/calls/call-1/interactions/question-1"); + expect(JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body))).toMatchObject({ + callId: "call-1", + interactionId: "question-1", + response: { selected: ["memory"] } + }); + }); +}); diff --git a/App/backend/src/adapters/outbound/plugin-runtime/tests/mcp-adapter.test.ts b/App/backend/src/adapters/outbound/plugin-runtime/tests/mcp-adapter.test.ts new file mode 100644 index 000000000..64610bf41 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/tests/mcp-adapter.test.ts @@ -0,0 +1,145 @@ +import type { CapabilityEvent } from "@memmy/local-api-contracts"; +import { describe, expect, it, vi } from "vitest"; +import { createMcpPluginAdapter } from "../mcp-adapter.js"; +import type { PluginRuntimeContext } from "../types.js"; + +function context(): PluginRuntimeContext { + const now = new Date().toISOString(); + return { + plugin: { + id: "com.example.mcp", + version: "1.0.0", + manifest: { + apiVersion: "memmy/v1", + id: "com.example.mcp", + name: "MCP plugin", + version: "1.0.0", + runtime: { + adapter: "mcp", + config: { + transport: "streamableHttp", + url: "https://plugin.example/mcp", + capabilityTools: { search: "search_papers" }, + secretHeaders: { authorization: "api-key" } + } + }, + capabilities: [{ + id: "search", + name: "Search", + description: "Search papers", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "request" + }], + permissions: [ + { type: "network", hosts: ["plugin.example"] }, + { type: "secret", keys: ["api-key"] } + ] + }, + state: "active", + approvedPermissions: [], + config: {}, + artifactHash: null, + rootPath: null, + lastError: null, + createdAt: now, + updatedAt: now + }, + config: {}, + secrets: { "api-key": "Bearer secret" }, + rootPath: null + }; +} + +async function collect(iterable: AsyncIterable): Promise { + const events: CapabilityEvent[] = []; + for await (const event of iterable) events.push(event); + return events; +} + +describe("McpPluginAdapter", () => { + it("maps MCP tools, progress, and structured output", async () => { + const client = { + connect: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + listTools: vi.fn(async () => ({ tools: [{ name: "search_papers" }] })), + callTool: vi.fn(async (_params, _schema, options) => { + options?.onprogress?.({ progress: 1, total: 2, message: "searching" }); + return { structuredContent: { papers: ["one"] } }; + }) + }; + const createTransport = vi.fn(() => ({ transport: "test" })); + const adapter = createMcpPluginAdapter({ createClient: () => client, createTransport }); + const session = await adapter.activate(context()); + expect(await collect(adapter.invoke(session, { + callId: "call-1", + pluginId: context().plugin.id, + capabilityId: "search", + conversationId: "conversation-1", + input: { query: "memory" } + }))).toEqual([ + { type: "progress", current: 1, total: 2, message: "searching" }, + { type: "result", output: { papers: ["one"] } } + ]); + expect(client.callTool).toHaveBeenCalledWith( + { name: "search_papers", arguments: { query: "memory" } }, + undefined, + expect.any(Object) + ); + expect(createTransport).toHaveBeenCalledWith(expect.any(Object), { authorization: "Bearer secret" }, expect.any(Object)); + }); + + it("fails activation when a mapped MCP tool is missing", async () => { + const client = { + connect: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + listTools: vi.fn(async () => ({ tools: [] })), + callTool: vi.fn() + }; + const adapter = createMcpPluginAdapter({ createClient: () => client, createTransport: () => ({}) }); + await expect(adapter.activate(context())).rejects.toThrow(/MCP tools not found/); + expect(client.close).toHaveBeenCalledOnce(); + }); + + it("starts local stdio MCP through the shared sandbox transport boundary", async () => { + const pluginContext = context(); + pluginContext.rootPath = "/plugin"; + pluginContext.plugin.rootPath = "/plugin"; + pluginContext.plugin.manifest.runtime.config = { + transport: "stdio", + command: "runtime/server", + secretEnv: { REVIEW_API_KEY: "api-key" } + }; + pluginContext.plugin.manifest.permissions = [{ type: "secret", keys: ["api-key"] }]; + const client = { + connect: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + listTools: vi.fn(async () => ({ tools: [{ name: "search" }] })), + callTool: vi.fn() + }; + const createTransport = vi.fn(() => ({ transport: "stdio-test" })); + const adapter = createMcpPluginAdapter({ + createClient: () => client, + createTransport, + platform: "darwin" + }); + + await adapter.activate(pluginContext); + + expect(createTransport).toHaveBeenCalledWith( + expect.objectContaining({ transport: "stdio", command: "runtime/server" }), + {}, + pluginContext + ); + }); + + it("rejects network permission for local stdio MCP", async () => { + const pluginContext = context(); + pluginContext.rootPath = "/plugin"; + pluginContext.plugin.rootPath = "/plugin"; + pluginContext.plugin.manifest.runtime.config = { transport: "stdio", command: "runtime/server" }; + const adapter = createMcpPluginAdapter({ platform: "darwin" }); + + await expect(adapter.activate(pluginContext)).rejects.toThrow(/cannot request network/); + }); +}); diff --git a/App/backend/src/adapters/outbound/plugin-runtime/tests/office-installer.test.ts b/App/backend/src/adapters/outbound/plugin-runtime/tests/office-installer.test.ts new file mode 100644 index 000000000..5620d832f --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/tests/office-installer.test.ts @@ -0,0 +1,69 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import JSZip from "jszip"; +import { afterEach, describe, expect, it } from "vitest"; +import { createOfficeRuntimeInstaller, OFFICE_TOOLS, safeRuntimePath, type OfficeRuntimeManifest } from "../office-installer.js"; + +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +const hash = (v: Buffer | string) => createHash("sha256").update(v).digest("hex"); +async function fixture() { + const cacheRoot = await mkdtemp(join(tmpdir(), "office-runtime-test-")); roots.push(cacheRoot); + const zip = new JSZip(), files: Record = {}, tools = {} as OfficeRuntimeManifest["tools"]; + for (const name of OFFICE_TOOLS) { + const file = `bin/${name}.exe`, bytes = Buffer.from(`fixture-${name}`); + zip.file(file, bytes, { unixPermissions: 0o100755 }); files[file] = hash(bytes); tools[name] = file; + } + const bytes = await zip.generateAsync({ type: "nodebuffer", platform: "UNIX" }); + const manifest: OfficeRuntimeManifest = { format: "memmy-office-runtime-v1", target: "win32-x64", version: "test-1", archive: { url: "/runtime.zip", sha256: hash(bytes), bytes: bytes.length }, tools, files }; + let downloads = 0, catalogs = 0, offline = false, corrupt = false; + const fetchFn = (async (input: URL | string) => { + if (offline) throw new Error("offline"); + if (String(input).endsWith("win32-x64")) { catalogs++; return Response.json(manifest); } + downloads++; return new Response(corrupt ? new Uint8Array(bytes.length) : bytes); + }) as typeof fetch; + const installer = createOfficeRuntimeInstaller({ cacheRoot, catalogUrl: "https://registry.example/runtimes/{target}", fetchFn }); + return { cacheRoot, installer, manifest, counts: () => ({ downloads, catalogs }), setOffline: () => { offline = true; }, setCorrupt: () => { corrupt = true; } }; +} +describe("Host Office runtime installation", () => { + it("installs once across concurrent callers, verifies every tool, and reuses its cache offline", async () => { + const f = await fixture(); + const [a, b] = await Promise.all([f.installer.ensure("win32", "x64"), f.installer.ensure("win32", "x64")]); + expect(a).toEqual(b); expect(f.counts()).toEqual({ downloads: 1, catalogs: 1 }); + expect(await readFile(a.tools.soffice!, "utf8")).toBe("fixture-soffice"); + f.setOffline(); expect(await f.installer.ensure("win32", "x64")).toEqual(a); + }); + it("repairs a corrupted file instead of trusting a completion marker", async () => { + const f = await fixture(), a = await f.installer.ensure("win32", "x64"); + await writeFile(a.tools.pdfinfo!, "corrupted"); await f.installer.ensure("win32", "x64"); + expect(await readFile(a.tools.pdfinfo!, "utf8")).toBe("fixture-pdfinfo"); expect(f.counts().downloads).toBe(2); + }); + it("rejects injected native libraries and restores the verified tree", async () => { + const f = await fixture(), installed = await f.installer.ensure("win32", "x64"); + await writeFile(join(installed.root, "bin", "injected.dll"), "not in manifest"); + await f.installer.ensure("win32", "x64"); + await expect(readFile(join(installed.root, "bin", "injected.dll"))).rejects.toThrow(); + expect(f.counts().downloads).toBe(2); + }); + it("retries bounded downloads and never commits an invalid archive", async () => { + const f = await fixture(); f.setCorrupt(); + await expect(f.installer.ensure("win32", "x64")).rejects.toThrow(/integrity/); + expect(f.counts().downloads).toBe(3); + await expect(readFile(join(f.cacheRoot, "win32-x64.json"))).rejects.toThrow(); + }); + it("rejects wrong targets, unmanifested ZIP entries, and missing tools", async () => { + const a = await fixture(); a.manifest.target = "darwin-arm64"; + await expect(a.installer.ensure("win32", "x64")).rejects.toThrow(/mismatch/); + const b = await fixture(); delete b.manifest.files[b.manifest.tools.pdftoppm]; + await expect(b.installer.ensure("win32", "x64")).rejects.toThrow(/Unexpected/); + const c = await fixture(); c.manifest.tools.soffice = "bin/missing.exe"; + await expect(c.installer.ensure("win32", "x64")).rejects.toThrow(/not covered/); + }); + it("refuses insecure origins and unsafe Windows/ZIP paths", () => { + expect(() => createOfficeRuntimeInstaller({ cacheRoot: tmpdir(), catalogUrl: "http://example.com/runtime" })).toThrow(/HTTPS/); + for (const value of ["../escape", "C:/escape", "bin\\escape", "bin/NUL.exe", "bin/a.", "bin/a ", "bin/a:b", "bin/", "bin/a?b", "/root", "bin//a"]) expect(safeRuntimePath(value), value).toBe(false); + expect(safeRuntimePath("LibreOffice/program/soffice.exe")).toBe(true); + }); +}); diff --git a/App/backend/src/adapters/outbound/plugin-runtime/tests/runtime-host.test.ts b/App/backend/src/adapters/outbound/plugin-runtime/tests/runtime-host.test.ts new file mode 100644 index 000000000..17322dce7 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/tests/runtime-host.test.ts @@ -0,0 +1,215 @@ +import type { CapabilityEvent } from "@memmy/local-api-contracts"; +import { describe, expect, it, vi } from "vitest"; +import { PluginAdapterRegistry } from "../registry.js"; +import { createPluginRuntimeHost } from "../runtime-host.js"; +import type { PluginAdapter, PluginRuntimeRecord } from "../types.js"; + +function plugin(): PluginRuntimeRecord { + const now = new Date().toISOString(); + return { + id: "com.example.echo", + version: "1.0.0", + manifest: { + apiVersion: "memmy/v1", + id: "com.example.echo", + name: "Echo", + version: "1.0.0", + runtime: { adapter: "http" }, + capabilities: [{ + id: "echo", + name: "Echo", + description: "Echoes text", + inputSchema: { + type: "object", + required: ["text"], + properties: { text: { type: "string" } }, + additionalProperties: false + }, + outputSchema: { + type: "object", + required: ["text"], + properties: { text: { type: "string" } } + }, + execution: "request" + }], + permissions: [] + }, + state: "active", + approvedPermissions: [], + config: {}, + artifactHash: null, + rootPath: null, + lastError: null, + createdAt: now, + updatedAt: now + }; +} + +function adapter(events: CapabilityEvent[]): PluginAdapter { + return { + id: "http", + validate: vi.fn(), + activate: vi.fn(async (context) => ({ pluginId: context.plugin.id })), + async *invoke() { + for (const event of events) yield event; + }, + respond: vi.fn(async () => undefined), + cancel: vi.fn(async () => undefined), + deactivate: vi.fn(async () => undefined) + }; +} + +async function collect(iterable: AsyncIterable): Promise { + const events: CapabilityEvent[] = []; + for await (const event of iterable) events.push(event); + return events; +} + +describe("PluginRuntimeHost", () => { + it("validates input and output without sharing plugin schema ids", async () => { + const runtimeAdapter = adapter([ + { type: "progress", current: 1, total: 1 }, + { type: "result", output: { text: "done" } } + ]); + const installed = plugin(); + installed.manifest.capabilities[0]!.inputSchema.$id = "shared"; + installed.manifest.capabilities[0]!.outputSchema.$id = "shared"; + const host = createPluginRuntimeHost(new PluginAdapterRegistry([runtimeAdapter])); + await host.activate(installed, {}); + + expect(await collect(host.invoke({ + callId: "call-1", + pluginId: "com.example.echo", + capabilityId: "echo", + conversationId: "conversation-1", + input: { text: "hello" } + }))).toEqual([ + { type: "progress", current: 1, total: 1 }, + { type: "result", output: { text: "done" } } + ]); + + expect(await collect(host.invoke({ + callId: "call-2", + pluginId: "com.example.echo", + capabilityId: "echo", + conversationId: "conversation-1", + input: {} + }))).toEqual([expect.objectContaining({ type: "error", code: "plugin_invalid" })]); + }); + + it("rejects invalid plugin output", async () => { + const host = createPluginRuntimeHost(new PluginAdapterRegistry([ + adapter([{ type: "result", output: { text: 42 } }]) + ])); + await host.activate(plugin(), {}); + expect(await collect(host.invoke({ + callId: "call-1", + pluginId: "com.example.echo", + capabilityId: "echo", + conversationId: "conversation-1", + input: { text: "hello" } + }))).toEqual([expect.objectContaining({ type: "error", code: "plugin_runtime_error" })]); + }); + + it("removes the active session before adapter shutdown", async () => { + const runtimeAdapter = adapter([{ type: "result", output: { text: "done" } }]); + const host = createPluginRuntimeHost(new PluginAdapterRegistry([runtimeAdapter])); + await host.activate(plugin(), {}); + await host.deactivate("com.example.echo"); + expect(runtimeAdapter.deactivate).toHaveBeenCalledOnce(); + expect(await collect(host.invoke({ + callId: "call-1", + pluginId: "com.example.echo", + capabilityId: "echo", + conversationId: "conversation-1", + input: { text: "hello" } + }))).toEqual([expect.objectContaining({ type: "error", code: "plugin_unavailable" })]); + }); + + it("validates and forwards a pending interaction response", async () => { + let releaseResult: (() => void) | undefined; + const runtimeAdapter = adapter([]); + runtimeAdapter.invoke = async function* () { + const gate = new Promise((resolve) => { + releaseResult = resolve; + }); + yield { + type: "interaction", + request: { + interactionId: "question-1", + type: "question", + payload: { title: "Scope" }, + responseSchema: { type: "string", minLength: 1 } + } + }; + await gate; + yield { type: "result", output: { text: "done" } }; + }; + const host = createPluginRuntimeHost(new PluginAdapterRegistry([runtimeAdapter])); + await host.activate(plugin(), {}); + const iterator = host.invoke({ + callId: "call-1", + pluginId: "com.example.echo", + capabilityId: "echo", + conversationId: "conversation-1", + input: { text: "hello" } + })[Symbol.asyncIterator](); + expect((await iterator.next()).value).toMatchObject({ type: "interaction" }); + await expect(host.respond("com.example.echo", "call-1", "question-1", "")).rejects.toThrow(/Invalid/); + await host.respond("com.example.echo", "call-1", "question-1", "focused"); + expect(runtimeAdapter.respond).toHaveBeenCalledWith(expect.any(Object), "call-1", "question-1", "focused"); + releaseResult?.(); + expect((await iterator.next()).value).toMatchObject({ type: "result" }); + }); + + it("routes a declared cancellation capability to one active run", async () => { + const targetGate = Promise.withResolvers(); + const runtimeAdapter = adapter([]); + runtimeAdapter.invoke = async function* (_session, call) { + if (call.capabilityId === "echo") { + yield { type: "progress", current: 0, total: 1, cancellable: true }; + await targetGate.promise; + return; + } + yield { type: "result", output: { text: "cancel recorded" } }; + }; + runtimeAdapter.cancel = vi.fn(async (_session, callId) => { + if (callId === "run-target") targetGate.resolve(); + }); + const installed = plugin(); + installed.manifest.capabilities.push({ + id: "cancel", + name: "Cancel", + description: "Cancel one run", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "request", + control: { action: "cancel", runIdInput: "runId", scopeInput: "scope", taskIdInput: "taskId" } + }); + installed.manifest.capabilities[0]!.inputSchema = { + type: "object", + required: ["text", "taskId"], + properties: { text: { type: "string" }, taskId: { type: "string" } }, + additionalProperties: false + }; + const host = createPluginRuntimeHost(new PluginAdapterRegistry([runtimeAdapter])); + await host.activate(installed, {}); + const target = host.invoke({ + callId: "run-target", pluginId: installed.id, capabilityId: "echo", conversationId: "conversation-1", input: { taskId: "task-1", text: "slow" } + })[Symbol.asyncIterator](); + expect((await target.next()).value).toMatchObject({ type: "progress", cancellable: true }); + + expect(await collect(host.invoke({ + callId: "run-cancel-wrong-task", pluginId: installed.id, capabilityId: "cancel", conversationId: "conversation-1", + input: { taskId: "task-2", scope: "run", runId: "run-target" } + }))).toEqual([{ type: "result", output: { text: "cancel recorded" } }]); + expect(runtimeAdapter.cancel).not.toHaveBeenCalled(); + + expect(await collect(host.invoke({ + callId: "run-cancel", pluginId: installed.id, capabilityId: "cancel", conversationId: "conversation-1", + input: { taskId: "task-1", scope: "run", runId: "run-target" } + }))).toEqual([{ type: "result", output: { text: "cancel recorded" } }]); + expect(runtimeAdapter.cancel).toHaveBeenCalledWith(expect.any(Object), "run-target"); + expect((await target.next()).value).toMatchObject({ type: "error", code: "plugin_cancelled", retryable: false }); + }); +}); diff --git a/App/backend/src/adapters/outbound/plugin-runtime/tests/windows-sandbox.test.ts b/App/backend/src/adapters/outbound/plugin-runtime/tests/windows-sandbox.test.ts new file mode 100644 index 000000000..b70e21a29 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/tests/windows-sandbox.test.ts @@ -0,0 +1,94 @@ +import { spawn } from "node:child_process"; +import { copyFile, mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; + +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +// Native tests exercise the packaged helper, not a fake spawn or a generated policy string. +describe.runIf(process.platform === "win32")("Windows AppContainer", () => { + it("allows approved files while denying private files, writes, loopback and descendant escapes", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "memmy-win-sandbox-"))); roots.push(root); + const allowed = join(root, "allowed"), denied = join(root, "private"); + await mkdir(allowed); await mkdir(denied); await writeFile(join(denied, "secret.txt"), "private"); + const node = await isolatedNode(root); + const server = createServer(socket => socket.end("forbidden")); + await new Promise(ok => server.listen(0, "127.0.0.1", ok)); + try { + const port = (server.address() as { port: number }).port; + const code = `const fs=require('fs'),net=require('net'),cp=require('child_process');(async()=>{ + let read=false,write=false; try{fs.readFileSync(${JSON.stringify(join(denied, "secret.txt"))});read=true}catch{} + try{fs.writeFileSync(${JSON.stringify(join(denied, "escape.txt"))},'escape');write=true}catch{} + fs.writeFileSync(${JSON.stringify(join(allowed, "ok.txt"))},'ok'); + const descendant=await new Promise((ok,reject)=>{const child=cp.spawn(process.execPath,['-e',${JSON.stringify(`try{require('fs').readFileSync(${JSON.stringify(join(denied, "secret.txt"))});process.exit(9)}catch{process.exit(0)}`)}],{stdio:'inherit'});child.once('error',reject);child.once('exit',status=>ok(status));}); + const profileReady=require('path').win32.isAbsolute(process.env.LOCALAPPDATA||''); + const socket=net.connect(${port},'127.0.0.1');let done=false;const finish=network=>{if(done)return;done=true;socket.destroy();console.log(JSON.stringify({read,write,network,descendant,profileReady}));}; + socket.on('connect',()=>finish(true));socket.on('error',()=>finish(false));setTimeout(()=>finish(false),1500);})().catch(error=>{console.error(error);process.exitCode=1});`; + await writeFile(join(allowed, "test.cjs"), code); + const plan = { profile: `memmy.plugin.test.${Date.now()}`, command: node, args: ["--preserve-symlinks", "--preserve-symlinks-main", join(allowed, "test.cjs")], cwd: allowed, readRoots: [dirname(node)], writeRoots: [allowed] }; + const result = await launch(plan); + expect(result.code, result.errors).toBe(0); + expect(JSON.parse(result.output)).toEqual({ read: false, write: false, network: false, descendant: 0, profileReady: true }); + } finally { await new Promise(ok => server.close(() => ok())); } + }, 180000); + it("kills the plugin and its descendants when the Host cancels the launcher", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "memmy-win-job-"))); roots.push(root); + const node = await isolatedNode(root); + const tick = join(root, "tick.txt"); + const childCode = `setInterval(()=>require('fs').writeFileSync(${JSON.stringify(tick)},String(Date.now())),100)`; + const parentCode = `const child=require('child_process').spawn(process.execPath,['-e',${JSON.stringify(childCode)}],{stdio:'inherit'});console.log(child.pid);setInterval(()=>{},1000);`; + const plan = { profile: `memmy.plugin.job.${Date.now()}`, command: node, args: ["-e", parentCode], cwd: root, readRoots: [dirname(node)], writeRoots: [root] }; + const child = spawnLauncher(plan); let errors = ""; + child.stderr.on("data", b => { errors += b; }); + try { + const pid = await new Promise((ok, reject) => { + const timer = setTimeout(() => reject(new Error(`No descendant started: ${errors}`)), 120000); + child.stdout.once("data", b => { clearTimeout(timer); ok(Number(String(b).trim())); }); + child.once("error", error => { clearTimeout(timer); reject(error); }); + child.once("exit", code => { clearTimeout(timer); reject(new Error(`Launcher exited ${code}: ${errors}`)); }); + }); + expect(pid).toBeGreaterThan(0); + await new Promise(ok => setTimeout(ok, 600)); + expect(await readFile(tick, "utf8")).toMatch(/^\d+$/); + child.kill(); + await new Promise(ok => setTimeout(ok, 1000)); + const stopped = await readFile(tick, "utf8"); + await new Promise(ok => setTimeout(ok, 500)); + expect(await readFile(tick, "utf8")).toBe(stopped); + expect(() => process.kill(pid, 0)).toThrow(); + } finally { child.kill(); } + }, 180000); +}); +async function isolatedNode(root: string) { + // Grant the actual interpreter, not the hosted tool cache's entire npm tree. + // Unique test profiles otherwise recursively rewrite thousands of shared ACLs. + const directory = join(root, "interpreter"); await mkdir(directory); + const executable = join(directory, "node.exe"); await copyFile(process.execPath, executable); + return executable; +} +async function launch(plan: unknown) { + return await new Promise<{code: number | null; output: string; errors: string}>((ok, reject) => { + const child = spawnLauncher(plan); + let output = "", errors = ""; child.stdout.on("data", b => { output += b; }); child.stderr.on("data", b => { errors += b; }); + const timer = setTimeout(() => { child.kill(); reject(new Error(`Sandbox launch timed out: ${errors}`)); }, 120000); + child.once("error", error => { clearTimeout(timer); reject(error); }); + child.once("close", code => { clearTimeout(timer); ok({code, output, errors}); }); child.stdin.end(); + }); +} + +function spawnLauncher(plan: unknown) { + // Deliberately omit LOCALAPPDATA: the Host launcher must restore its own + // known-folder value before CreateProcessW, without inheriting plugin values. + const helper = fileURLToPath(new URL("../windows/launch.ps1", import.meta.url)); + return spawn(join(process.env.SystemRoot ?? "C:\\Windows", "System32/WindowsPowerShell/v1.0/powershell.exe"), ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", helper, "-Plan", Buffer.from(JSON.stringify(plan)).toString("base64")], { stdio: ["pipe", "pipe", "pipe"], env: { + SystemRoot: process.env.SystemRoot ?? "C:\\Windows", + WINDIR: process.env.SystemRoot ?? "C:\\Windows", + PATH: join(process.env.SystemRoot ?? "C:\\Windows", "System32"), + PSModulePath: join(process.env.SystemRoot ?? "C:\\Windows", "System32/WindowsPowerShell/v1.0/Modules"), + MEMMY_SANDBOX_TRACE: "1", + MEMMY_SANDBOX_CHILD_ENV: Buffer.from(JSON.stringify({ SystemRoot: process.env.SystemRoot ?? "C:\\Windows", LOCALAPPDATA: "untrusted-profile", TEMP: tmpdir(), TMP: tmpdir(), ELECTRON_RUN_AS_NODE: "1" })).toString("base64") + } }); +} diff --git a/App/backend/src/adapters/outbound/plugin-runtime/types.ts b/App/backend/src/adapters/outbound/plugin-runtime/types.ts new file mode 100644 index 000000000..b6b46a4f6 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/types.ts @@ -0,0 +1,59 @@ +/** Plugin runtime adapter contracts. */ +import type { + CapabilityCall, + CapabilityEvent, + InstalledPlugin, + PluginRuntime +} from "@memmy/local-api-contracts"; + +export interface PluginHostServiceCall { + pluginId: string; + callId: string; + conversationId: string; + /** Exact Agent preset selected for the conversation turn, when available. */ + modelPreset?: string; + service: string; + input: unknown; + deadline?: string; + /** Aborted when the owning plugin capability call is cancelled or terminated. */ + signal?: AbortSignal; +} + +export interface PluginHostServiceInvoker { + invoke(call: PluginHostServiceCall): Promise; +} + +export interface PluginRuntimeRecord extends InstalledPlugin { + artifactHash: string | null; + rootPath: string | null; +} + +export interface PluginRuntimeContext { + plugin: PluginRuntimeRecord; + config: Readonly>; + secrets: Readonly>; + rootPath: string | null; +} + +export interface PluginSession { + readonly pluginId: string; +} + +export interface PluginAdapter { + readonly id: "mcp" | "http" | "command"; + validate(runtime: PluginRuntime, rootPath: string | null): void; + activate(context: PluginRuntimeContext): Promise; + invoke(session: PluginSession, call: CapabilityCall): AsyncIterable; + respond?(session: PluginSession, callId: string, interactionId: string, response: unknown): Promise; + cancel?(session: PluginSession, callId: string): Promise; + deactivate(session: PluginSession): Promise; +} + +export interface PluginRuntimeHost { + supports(adapterId: string): boolean; + activate(plugin: PluginRuntimeRecord, secrets: Readonly>): Promise; + deactivate(pluginId: string): Promise; + invoke(call: CapabilityCall): AsyncIterable; + cancel(pluginId: string, callId: string): Promise; + respond(pluginId: string, callId: string, interactionId: string, response: unknown): Promise; +} diff --git a/App/backend/src/adapters/outbound/plugin-runtime/windows/Sandbox.cs b/App/backend/src/adapters/outbound/plugin-runtime/windows/Sandbox.cs new file mode 100644 index 000000000..1060e9609 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/windows/Sandbox.cs @@ -0,0 +1,203 @@ +// Host-only AppContainer launcher. No network capabilities and no direct-launch fallback. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Threading; + +namespace Memmy { + public static class PluginSandbox { + [StructLayout(LayoutKind.Sequential)] struct SecurityCapabilities { + public IntPtr Sid, Capabilities; + public uint Count, Reserved; + } + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct StartupInfo { + public int cb; public string reserved, desktop, title; + public uint x, y, xSize, ySize, xCountChars, yCountChars, fillAttribute, flags; + public ushort showWindow, reserved2; public IntPtr reservedPtr, stdin, stdout, stderr; + } + [StructLayout(LayoutKind.Sequential)] struct StartupInfoEx { public StartupInfo info; public IntPtr attributes; } + [StructLayout(LayoutKind.Sequential)] struct ProcessInfo { public IntPtr process, thread; public uint processId, threadId; } + [StructLayout(LayoutKind.Sequential)] struct SecurityAttributes { public int length; public IntPtr descriptor; [MarshalAs(UnmanagedType.Bool)] public bool inherit; } + [StructLayout(LayoutKind.Sequential)] struct BasicJobLimit { + public long perProcessTime, perJobTime; public uint flags; + public UIntPtr minWorkingSet, maxWorkingSet; public uint activeProcessLimit; + public UIntPtr affinity; public uint priorityClass, schedulingClass; + } + [StructLayout(LayoutKind.Sequential)] struct IoCounters { public ulong rOp, wOp, oOp, rBytes, wBytes, oBytes; } + [StructLayout(LayoutKind.Sequential)] struct ExtendedJobLimit { + public BasicJobLimit basic; public IoCounters io; + public UIntPtr processMemory, jobMemory, peakProcessMemory, peakJobMemory; + } + [DllImport("userenv.dll", CharSet=CharSet.Unicode)] static extern int CreateAppContainerProfile(string name, string display, string description, IntPtr caps, uint count, out IntPtr sid); + [DllImport("userenv.dll", CharSet=CharSet.Unicode)] static extern int DeriveAppContainerSidFromAppContainerName(string name, out IntPtr sid); + [DllImport("advapi32.dll")] static extern IntPtr FreeSid(IntPtr sid); + [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool ConvertStringSecurityDescriptorToSecurityDescriptorW(string value, uint revision, out IntPtr descriptor, IntPtr size); + [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory); + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateWindowStationW(string name, uint flags, uint access, ref SecurityAttributes attributes); + [DllImport("user32.dll", SetLastError=true)] static extern IntPtr GetProcessWindowStation(); + [DllImport("user32.dll", SetLastError=true)] static extern bool SetProcessWindowStation(IntPtr station); + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateDesktopW(string name, string device, IntPtr mode, uint flags, uint access, ref SecurityAttributes attributes); + [DllImport("user32.dll")] static extern bool CloseDesktop(IntPtr desktop); + [DllImport("user32.dll")] static extern bool CloseWindowStation(IntPtr station); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref UIntPtr size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, UIntPtr attribute, IntPtr value, UIntPtr size, IntPtr previous, IntPtr returned); + [DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern bool CreateProcessW(string application, StringBuilder command, IntPtr processSecurity, IntPtr threadSecurity, bool inherit, uint flags, IntPtr environment, string cwd, ref StartupInfoEx startup, out ProcessInfo process); + [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern IntPtr CreateJobObject(IntPtr security, string name); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int kind, ref ExtendedJobLimit limits, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint WaitForSingleObject(IntPtr handle, uint timeout); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process, out uint code); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr GetStdHandle(int handle); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool DuplicateHandle(IntPtr sourceProcess, IntPtr source, IntPtr targetProcess, out IntPtr target, uint access, bool inherit, uint options); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + + static void Check(bool success, string operation = "Win32 sandbox operation") { + if (!success) { + int code = Marshal.GetLastWin32Error(); + throw new Win32Exception(code, operation + " failed (Win32 " + code + "): " + new Win32Exception(code).Message); + } + } + static void Trace(string stage) { + if (Environment.GetEnvironmentVariable("MEMMY_SANDBOX_TRACE") == "1") Console.Error.WriteLine("Memmy AppContainer: " + stage); + } + + static string IsolatedDesktop(SecurityIdentifier identity, out IntPtr station, out IntPtr desktop) { + station = IntPtr.Zero; desktop = IntPtr.Zero; + IntPtr descriptor = IntPtr.Zero, previous = GetProcessWindowStation(); + string name = "Memmy_" + Guid.NewGuid().ToString("N"); + // A private, non-interactive window station lets rendering libraries use + // GDI/internal windows without access to the user's desktop or clipboard. + string owner = WindowsIdentity.GetCurrent().User.Value; + string security = "D:P(A;;GA;;;SY)(A;;GA;;;" + owner + ")(A;;GA;;;" + identity.Value + ")S:(ML;;NW;;;LW)"; + try { + Check(ConvertStringSecurityDescriptorToSecurityDescriptorW(security, 1, out descriptor, IntPtr.Zero), "Private desktop security"); + var attributes = new SecurityAttributes { length = Marshal.SizeOf(typeof(SecurityAttributes)), descriptor = descriptor, inherit = false }; + station = CreateWindowStationW(name, 0, 0x10000000, ref attributes); + Check(station != IntPtr.Zero, "CreateWindowStationW"); + Check(SetProcessWindowStation(station), "SetProcessWindowStation"); + try { + desktop = CreateDesktopW("Office", null, IntPtr.Zero, 0, 0x10000000, ref attributes); + Check(desktop != IntPtr.Zero, "CreateDesktopW"); + } finally { Check(SetProcessWindowStation(previous), "Restore window station"); } + return name + "\\Office"; + } finally { if (descriptor != IntPtr.Zero) LocalFree(descriptor); } + } + static string Quote(string value) { + var result = new StringBuilder("\""); int slashes = 0; + foreach (char c in value) { + if (c == '\\') { slashes++; continue; } + if (c == '"') result.Append('\\', slashes * 2 + 1).Append(c); + else result.Append('\\', slashes).Append(c); + slashes = 0; + } + return result.Append('\\', slashes * 2).Append('"').ToString(); + } + static void Grant(string directory, SecurityIdentifier sid, bool writable) { + if (!Path.IsPathRooted(directory) || !Directory.Exists(directory)) throw new IOException("Invalid sandbox root: " + directory); + if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) throw new IOException("Sandbox roots must be canonical directories"); + var acl = Directory.GetAccessControl(directory); + var rights = writable ? FileSystemRights.Modify : FileSystemRights.ReadAndExecute; + foreach (FileSystemAccessRule rule in acl.GetAccessRules(true, true, typeof(SecurityIdentifier))) { + var existing = (SecurityIdentifier) rule.IdentityReference; + bool matching = existing.Equals(sid) || (!writable && existing.Value == "S-1-15-2-1"); + if (matching && rule.AccessControlType == AccessControlType.Allow && (rule.FileSystemRights & rights) == rights + && (rule.InheritanceFlags & (InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit)) == (InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit)) return; + } + acl.AddAccessRule(new FileSystemAccessRule(sid, rights, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + Directory.SetAccessControl(directory, acl); + } + + public static int Run(string profile, string command, string[] args, string cwd, string[] readRoots, string[] writeRoots, Dictionary environment) { + if (!profile.StartsWith("memmy.plugin.", StringComparison.Ordinal) || !Path.IsPathRooted(command) || !File.Exists(command)) throw new ArgumentException("Invalid sandbox launch plan"); + IntPtr sid = IntPtr.Zero, attributes = IntPtr.Zero, capsPointer = IntPtr.Zero, handlesPointer = IntPtr.Zero, job = IntPtr.Zero, environmentPointer = IntPtr.Zero, station = IntPtr.Zero, desktop = IntPtr.Zero; + var inherited = new List(); var process = new ProcessInfo(); bool resumed = false, attributesInitialized = false; + try { + Trace("creating profile"); + int status = CreateAppContainerProfile(profile, "Memmy plugin", "Memmy isolated command plugin", IntPtr.Zero, 0, out sid); + if (status == unchecked((int)0x800700B7)) status = DeriveAppContainerSidFromAppContainerName(profile, out sid); + if (status < 0) Marshal.ThrowExceptionForHR(status); + var identity = new SecurityIdentifier(sid); + Trace("granting approved roots"); + using (var mutex = new Mutex(false, "Local\\MemmyPluginSandboxPermissions")) { + bool held = false; + try { + try { held = mutex.WaitOne(30000); } catch (AbandonedMutexException) { held = true; } + if (!held) throw new TimeoutException("Sandbox ACL setup timed out"); + foreach (var root in readRoots) Grant(root, identity, false); + foreach (var root in writeRoots) Grant(root, identity, true); + } finally { if (held) mutex.ReleaseMutex(); } + } + Trace("approved roots ready"); + foreach (int number in new int[] { -10, -11, -12 }) { + IntPtr handle; + Check(DuplicateHandle(GetCurrentProcess(), GetStdHandle(number), GetCurrentProcess(), out handle, 0, true, 2)); + inherited.Add(handle); + } + UIntPtr attributeSize = UIntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 2, 0, ref attributeSize); + attributes = Marshal.AllocHGlobal(checked((int)attributeSize.ToUInt64())); + Check(InitializeProcThreadAttributeList(attributes, 2, 0, ref attributeSize)); attributesInitialized = true; + var caps = new SecurityCapabilities { Sid = sid, Capabilities = IntPtr.Zero, Count = 0, Reserved = 0 }; + capsPointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(SecurityCapabilities))); + Marshal.StructureToPtr(caps, capsPointer, false); + Check(UpdateProcThreadAttribute(attributes, 0, new UIntPtr(0x00020009), capsPointer, new UIntPtr((uint)Marshal.SizeOf(typeof(SecurityCapabilities))), IntPtr.Zero, IntPtr.Zero)); + handlesPointer = Marshal.AllocHGlobal(IntPtr.Size * inherited.Count); + for (int i=0; i(environment, StringComparer.OrdinalIgnoreCase); + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (String.IsNullOrEmpty(localAppData) || !Path.IsPathRooted(localAppData)) throw new InvalidOperationException("LocalApplicationData is unavailable"); + environment["LOCALAPPDATA"] = localAppData; + var variables = new List(environment.Keys); variables.Sort(StringComparer.OrdinalIgnoreCase); + var block = new StringBuilder(); + foreach (var key in variables) { + string value = environment[key]; + if (String.IsNullOrEmpty(key) || key.IndexOf('=') >= 0 || key.IndexOf('\0') >= 0 || value.IndexOf('\0') >= 0) throw new ArgumentException("Invalid child environment"); + block.Append(key).Append('=').Append(value).Append('\0'); + } + block.Append('\0'); environmentPointer = Marshal.StringToHGlobalUni(block.ToString()); + Trace("creating child process"); + Check(CreateProcessW(command, line, IntPtr.Zero, IntPtr.Zero, true, 0x00080000 | 0x00000004 | 0x08000000 | 0x00000400, environmentPointer, cwd, ref startup, out process), "CreateProcessW(AppContainer)"); + Check(AssignProcessToJobObject(job, process.process)); + if (ResumeThread(process.thread) == uint.MaxValue) Check(false); + resumed = true; + Trace("child running"); + if (WaitForSingleObject(process.process, uint.MaxValue) != 0) Check(false); + uint exitCode; Check(GetExitCodeProcess(process.process, out exitCode)); return unchecked((int)exitCode); + } finally { + if (process.process != IntPtr.Zero && !resumed) TerminateProcess(process.process, 125); + if (job != IntPtr.Zero) CloseHandle(job); // Also kills descendants after cancellation/normal exit. + if (process.thread != IntPtr.Zero) CloseHandle(process.thread); + if (process.process != IntPtr.Zero) CloseHandle(process.process); + foreach (var handle in inherited) CloseHandle(handle); + if (attributes != IntPtr.Zero) { if (attributesInitialized) DeleteProcThreadAttributeList(attributes); Marshal.FreeHGlobal(attributes); } + if (capsPointer != IntPtr.Zero) Marshal.FreeHGlobal(capsPointer); + if (handlesPointer != IntPtr.Zero) Marshal.FreeHGlobal(handlesPointer); + if (environmentPointer != IntPtr.Zero) Marshal.FreeHGlobal(environmentPointer); + if (desktop != IntPtr.Zero) CloseDesktop(desktop); + if (station != IntPtr.Zero) CloseWindowStation(station); + if (sid != IntPtr.Zero) FreeSid(sid); + } + } + } +} diff --git a/App/backend/src/adapters/outbound/plugin-runtime/windows/launch.ps1 b/App/backend/src/adapters/outbound/plugin-runtime/windows/launch.ps1 new file mode 100644 index 000000000..b464239b3 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-runtime/windows/launch.ps1 @@ -0,0 +1,62 @@ +param([Parameter(Mandatory=$true)][string]$Plan) +$ErrorActionPreference = 'Stop' +try { + if ($env:MEMMY_SANDBOX_TRACE -eq '1') { [Console]::Error.WriteLine('Memmy AppContainer: preparing launcher') } + $request = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Plan)) | ConvertFrom-Json + # Compilation runs at Host privilege: never use a plugin-writable TEMP. + # CreateProcessW resolves the AppContainer profile from the launcher's + # LOCALAPPDATA even when an explicit child environment block is provided. + # The Host strips inherited variables; derive this value from the current + # user's known folder, never from the plugin's child environment. + $localAppData = [Environment]::GetFolderPath('LocalApplicationData') + if ([String]::IsNullOrWhiteSpace($localAppData) -or ![IO.Path]::IsPathRooted($localAppData)) { throw 'LocalApplicationData is unavailable' } + $env:LOCALAPPDATA = $localAppData + $compilerRoot = Join-Path $localAppData 'Memmy/SandboxCompiler' + [IO.Directory]::CreateDirectory($compilerRoot) | Out-Null + if (([IO.File]::GetAttributes($compilerRoot) -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Unsafe compiler directory' } + $acl = New-Object System.Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $acl.AddAccessRule($rule) + [IO.Directory]::SetAccessControl($compilerRoot, $acl) + $env:TEMP = $compilerRoot + $env:TMP = $compilerRoot + # Cache only Host-packaged source in the private compiler directory. Reusing + # its assembly avoids invoking the C# compiler for every document operation. + $source = Join-Path $PSScriptRoot 'Sandbox.cs' + $digest = [Security.Cryptography.SHA256]::Create() + try { $hash = [BitConverter]::ToString($digest.ComputeHash([IO.File]::ReadAllBytes($source))).Replace('-', '').ToLowerInvariant() } + finally { $digest.Dispose() } + $assembly = Join-Path $compilerRoot ("sandbox-" + $hash + '.dll') + $mutex = [Threading.Mutex]::new($false, ("Local\MemmySandboxCompiler-" + $hash)) + $held = $false + try { + try { $held = $mutex.WaitOne(120000) } catch [Threading.AbandonedMutexException] { $held = $true } + if (!$held) { throw 'Sandbox compiler cache is busy' } + if ([IO.File]::Exists($assembly)) { + if (([IO.File]::GetAttributes($assembly) -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Unsafe compiler cache file' } + try { [void][Reflection.AssemblyName]::GetAssemblyName($assembly) } + catch { [IO.File]::Delete($assembly) } + } + if (![IO.File]::Exists($assembly)) { + if ($env:MEMMY_SANDBOX_TRACE -eq '1') { [Console]::Error.WriteLine('Memmy AppContainer: compiling launcher') } + [Environment]::CurrentDirectory = $compilerRoot + Add-Type -Path $source -OutputAssembly $assembly + } + Add-Type -Path $assembly + } finally { + if ($held) { $mutex.ReleaseMutex() } + $mutex.Dispose() + } + $childEnvironment = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:MEMMY_SANDBOX_CHILD_ENV)) | ConvertFrom-Json + $dictionary = New-Object 'System.Collections.Generic.Dictionary[string,string]' + foreach ($property in $childEnvironment.PSObject.Properties) { $dictionary.Add($property.Name, [string]$property.Value) } + $result = [Memmy.PluginSandbox]::Run( + [string]$request.profile, [string]$request.command, [string[]]$request.args, + [string]$request.cwd, [string[]]$request.readRoots, [string[]]$request.writeRoots, $dictionary) + exit $result +} catch { + [Console]::Error.WriteLine($_.Exception.ToString()) + exit 125 +} diff --git a/App/backend/src/adapters/outbound/plugin-skill/index.ts b/App/backend/src/adapters/outbound/plugin-skill/index.ts new file mode 100644 index 000000000..81f2184d6 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-skill/index.ts @@ -0,0 +1,109 @@ +/** Host-managed registration of skills contributed by active MPP plugins. */ +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { PluginRuntimeRecord } from "../plugin-runtime/types.js"; + +const OWNER_FILE = ".memmy-plugin-owner.json"; + +export interface PluginSkillManager { + activate(plugin: PluginRuntimeRecord): Promise; + deactivate(pluginId: string): Promise; +} + +export interface CreatePluginSkillManagerOptions { + skillsRoot: string; +} + +export function createPluginSkillManager(options: CreatePluginSkillManagerOptions): PluginSkillManager { + const configuredRoot = resolve(options.skillsRoot); + return { + async activate(plugin) { + await mkdir(configuredRoot, { recursive: true }); + const skillsRoot = await realpath(configuredRoot); + await this.deactivate(plugin.id); + for (const skill of plugin.manifest.skills ?? []) { + if (!plugin.rootPath) throw new Error(`Plugin skill requires an installed artifact: ${plugin.id}`); + const pluginRoot = await realpath(plugin.rootPath); + const sourceEntry = resolve(pluginRoot, skill.entry); + assertDescendant(pluginRoot, sourceEntry); + const entryInfo = await lstat(sourceEntry); + if (!entryInfo.isFile() || entryInfo.isSymbolicLink() || await realpath(sourceEntry) !== sourceEntry) { + throw new Error(`Plugin skill entry must be a regular file: ${skill.entry}`); + } + const sourceDirectory = dirname(sourceEntry); + const target = resolve(skillsRoot, skill.id); + assertDescendant(skillsRoot, target); + await assertAvailableTarget(target, plugin.id); + const temp = await mkdtemp(join(skillsRoot, ".plugin-skill-")); + // `cp` refuses to write into a directory that already exists while + // `errorOnExist` is set, and mkdtemp just created one, so stage inside + // it rather than onto it. + const staged = join(temp, "skill"); + try { + if (skill.cloud) { + // A cloud Skill contributes only a public loader, never sibling private references. + const bootstrap = await readFile(sourceEntry, "utf8"); + if (Buffer.byteLength(bootstrap) > 4096 || !bootstrap.includes("mcp_plugins_read_plugin_skill")) throw new Error("Invalid cloud Skill bootstrap"); + await mkdir(staged); + await writeFile(join(staged, "SKILL.md"), bootstrap, "utf8"); + } else { + await cp(sourceDirectory, staged, { recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true }); + } + const copiedEntry = join(staged, "SKILL.md"); + if (!(await lstat(copiedEntry)).isFile()) throw new Error(`Plugin skill directory does not contain SKILL.md: ${skill.entry}`); + await writeFile(join(staged, OWNER_FILE), JSON.stringify({ pluginId: plugin.id, skillId: skill.id, version: plugin.version }), "utf8"); + await rm(target, { recursive: true, force: true }); + await rename(staged, target); + } finally { + await rm(temp, { recursive: true, force: true }); + } + } + }, + + async deactivate(pluginId) { + const entries = await readdir(configuredRoot, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return []; + throw error; + }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".plugin-skill-")) continue; + const target = resolve(configuredRoot, entry.name); + const owner = await readOwner(target); + if (owner?.pluginId === pluginId) await rm(target, { recursive: true, force: true }); + } + } + }; +} + +export const noopPluginSkillManager: PluginSkillManager = { + async activate() {}, + async deactivate() {} +}; + +async function assertAvailableTarget(target: string, pluginId: string): Promise { + const info = await lstat(target).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!info) return; + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Plugin skill target is not a safe directory: ${target}`); + const owner = await readOwner(target); + if (owner?.pluginId !== pluginId) throw new Error(`Plugin skill id is already registered: ${target}`); +} + +async function readOwner(directory: string): Promise<{ pluginId: string } | null> { + try { + const value = JSON.parse(await readFile(join(directory, OWNER_FILE), "utf8")) as { pluginId?: unknown }; + return typeof value.pluginId === "string" ? { pluginId: value.pluginId } : null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return null; + throw error; + } +} + +function assertDescendant(parent: string, child: string): void { + const path = relative(resolve(parent), resolve(child)); + if (!path || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) { + throw new Error("Plugin skill path escapes its artifact root"); + } +} diff --git a/App/backend/src/adapters/outbound/plugin-skill/tests/plugin-skill.test.ts b/App/backend/src/adapters/outbound/plugin-skill/tests/plugin-skill.test.ts new file mode 100644 index 000000000..305c2d642 --- /dev/null +++ b/App/backend/src/adapters/outbound/plugin-skill/tests/plugin-skill.test.ts @@ -0,0 +1,56 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPluginSkillManager } from "../index.js"; +import type { PluginRuntimeRecord } from "../../plugin-runtime/types.js"; + +let root: string | undefined; +afterEach(() => { if (root) rmSync(root, { recursive: true, force: true }); root = undefined; }); + +describe("PluginSkillManager", () => { + it("registers and unregisters a packaged skill with plugin ownership", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-skill-")); + const artifact = join(root, "artifact"); + const skillsRoot = join(root, "workspace", "skills"); + mkdirSync(join(artifact, "skills", "review", "references"), { recursive: true }); + writeFileSync(join(artifact, "skills", "review", "SKILL.md"), "---\nname: review\ndescription: test\n---\nUse it."); + writeFileSync(join(artifact, "skills", "review", "references", "guide.md"), "guide"); + const manager = createPluginSkillManager({ skillsRoot }); + await manager.activate(plugin(artifact)); + expect(readFileSync(join(skillsRoot, "review", "SKILL.md"), "utf8")).toContain("Use it."); + expect(readFileSync(join(skillsRoot, "review", "references", "guide.md"), "utf8")).toBe("guide"); + await manager.deactivate("com.example.review"); + expect(() => readFileSync(join(skillsRoot, "review", "SKILL.md"), "utf8")).toThrow(); + }); + it("copies only the public entry for cloud Skills and replaces previous local instructions", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-skill-")); + const artifact = join(root, "artifact"); const skillsRoot = join(root, "workspace", "skills"); + mkdirSync(join(artifact, "skills", "review", "references"), { recursive: true }); + writeFileSync(join(artifact, "skills", "review", "SKILL.md"), "old private instructions"); + writeFileSync(join(artifact, "skills", "review", "references", "guide.md"), "private reference"); + const manager = createPluginSkillManager({ skillsRoot }); const record = plugin(artifact); + await manager.activate(record); + writeFileSync(join(artifact, "skills", "review", "SKILL.md"), "Public entry: mcp_plugins_read_plugin_skill"); + record.manifest.skills![0].cloud = { revision: "r1", sha256: "a".repeat(64) }; + await manager.activate(record); + expect(readFileSync(join(skillsRoot, "review", "SKILL.md"), "utf8")).not.toContain("private instructions"); + expect(() => readFileSync(join(skillsRoot, "review", "references", "guide.md"), "utf8")).toThrow(); + }); + +}); + +function plugin(rootPath: string): PluginRuntimeRecord { + const now = new Date().toISOString(); + return { + id: "com.example.review", version: "1.0.0", state: "active", approvedPermissions: [], config: {}, + artifactHash: "hash", rootPath, lastError: null, createdAt: now, updatedAt: now, + manifest: { + apiVersion: "memmy/v1", id: "com.example.review", name: "Review", version: "1.0.0", + runtime: { adapter: "command", config: { command: "runtime/review" } }, + capabilities: [{ id: "run", name: "Run", description: "Run", inputSchema: {}, outputSchema: {}, execution: "request" }], + permissions: [], + skills: [{ id: "review", name: "Review", description: "Review", entry: "skills/review/SKILL.md" }] + } + }; +} diff --git a/App/backend/src/config/service-urls.ts b/App/backend/src/config/service-urls.ts index 1b1368c50..fcd5c0850 100644 --- a/App/backend/src/config/service-urls.ts +++ b/App/backend/src/config/service-urls.ts @@ -15,3 +15,24 @@ export function resolveCloudClientConfig(env: NodeJS.ProcessEnv): CloudClientCon timeoutMs: Number.parseInt(env.MEMMY_CLOUD_TIMEOUT_MS ?? "5000", 10) }; } + +/** + * Resolves the base URL of the plugin registry. + * + * Entitlement-gated releases are served by the Cloud service itself, so the + * registry defaults to the Cloud base URL instead of being configured (and + * kept in sync) separately. `MEMMY_PLUGIN_REGISTRY_URL` overrides it, which is + * what a loopback development registry uses. + * + * @param env process environment. + * @returns the registry base URL, or undefined when no Cloud service is configured either. + */ +export function resolvePluginRegistryBaseUrl(env: NodeJS.ProcessEnv): string | undefined { + const override = env.MEMMY_PLUGIN_REGISTRY_URL?.trim(); + if (override) return override; + try { + return resolveCloudClientConfig(env).baseUrl; + } catch { + return undefined; + } +} diff --git a/App/backend/src/config/tests/service-urls.test.ts b/App/backend/src/config/tests/service-urls.test.ts index 1fa8bbd5f..f1291ed1f 100644 --- a/App/backend/src/config/tests/service-urls.test.ts +++ b/App/backend/src/config/tests/service-urls.test.ts @@ -1,6 +1,6 @@ /** Service urls tests. */ import { describe, expect, it } from "vitest"; -import { resolveCloudClientConfig } from "../service-urls.js"; +import { resolveCloudClientConfig, resolvePluginRegistryBaseUrl } from "../service-urls.js"; describe("service URL config", () => { it("默认网关来自 MEMMY_CLOUD_SERVICE", () => { @@ -20,6 +20,33 @@ describe("service URL config", () => { expect(() => resolveCloudClientConfig({})).toThrow(/MEMMY_CLOUD_SERVICE/); }); + it("插件注册中心默认跟随云服务地址", () => { + expect(resolvePluginRegistryBaseUrl({ MEMMY_CLOUD_SERVICE: "https://gw.example.cn" })) + .toBe("https://gw.example.cn"); + }); + + it("MEMMY_PLUGIN_REGISTRY_URL 可覆盖注册中心地址", () => { + expect( + resolvePluginRegistryBaseUrl({ + MEMMY_CLOUD_SERVICE: "https://gw.example.cn", + MEMMY_PLUGIN_REGISTRY_URL: " http://127.0.0.1:4100 " + }) + ).toBe("http://127.0.0.1:4100"); + }); + + it("没有云服务也没有覆盖时不返回地址,而不是抛错", () => { + expect(resolvePluginRegistryBaseUrl({})).toBeUndefined(); + }); + + it("注册中心跟随 MEMMY_CLOUD_URL 本地调试地址", () => { + expect( + resolvePluginRegistryBaseUrl({ + MEMMY_CLOUD_SERVICE: "https://gw.example.cn", + MEMMY_CLOUD_URL: "http://127.0.0.1:3000" + }) + ).toBe("http://127.0.0.1:3000"); + }); + it("lets MEMMY_CLOUD_URL override the built-in Cloud URL for local debugging", () => { expect( resolveCloudClientConfig({ diff --git a/App/backend/src/index.ts b/App/backend/src/index.ts index d441d389f..b9a47f990 100644 --- a/App/backend/src/index.ts +++ b/App/backend/src/index.ts @@ -14,6 +14,7 @@ import { import { resolveDefaultRuntimeConfigPath, writeRuntimeConfigFile } from "./infrastructure/cli-binary/index.js"; import { createMemmyConfigWriter, + readBundledPluginPreferences, readConfiguredAgentTimeZone, readAgentGatewayBootstrapSecret } from "./infrastructure/memmy-config/index.js"; @@ -24,7 +25,12 @@ import { import { createPermissionManager } from "./permission/index.js"; import { createLocalApiServer } from "./adapters/inbound/local-api/server.js"; import { createBackendServices, type BootstrapScenario } from "./services/index.js"; -import { resolveCloudClientConfig, type CloudClientConfig } from "./config/service-urls.js"; +import type { PluginService } from "./services/plugin-service.js"; +import { + resolveCloudClientConfig, + resolvePluginRegistryBaseUrl, + type CloudClientConfig +} from "./config/service-urls.js"; import { resetAccountRuntimeForDesktopInstallChange } from "./services/desktop-install-state-service.js"; import { syncRuntimeConfigForStartup, @@ -32,6 +38,18 @@ import { } from "./services/runtime-config-sync-service.js"; import { loadCloudServiceEnv } from "./load-env.js"; import type { MemmyAgentAdminClient } from "./adapters/outbound/memmy-agent-admin-client/index.js"; +import { + createCompositePluginRegistry, + createHttpPluginRegistry, + loadBundledPluginCatalog, + type BundledPluginCatalog, + type PluginRegistry +} from "./adapters/outbound/plugin-registry/index.js"; +import { + reconcileBundledPlugins, + suppressBundledPlugins +} from "./services/bundled-plugin-bootstrap-service.js"; +import { reconcileEntitledPlugins } from "./services/plugin-entitlement-reconcile-service.js"; export type { BootstrapScenario }; export { loadCloudServiceEnv }; @@ -63,6 +81,8 @@ export interface CreateLocalBackendOptions { accountChannel?: AccountChannel; /** Running Agent Gateway client; when present, refreshes MCP after startup config writes. */ memmyAgentAdminClient?: MemmyAgentAdminClient; + /** Directory containing immutable `*.release.json` files and their MPP archives. */ + bundledPluginDirectory?: string; /** Skip only the startup MCP reload when the caller will start the Agent after bridge config is written. */ skipAgentMcpReload?: boolean; } @@ -84,6 +104,7 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr } const appStateStore = createAppStateStore({ databasePath: options.databasePath }); let server: Awaited> | null = null; + let pluginService: PluginService | null = null; try { if (options.desktopInstallFingerprint) { @@ -137,6 +158,15 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr pluginDirectories: options.agentAdapterPluginDirectories }); const configuredTimeZone = await readConfiguredAgentTimeZone(memmyConfigPath); + const bundledCatalog = await tryLoadBundledPluginCatalog( + options.bundledPluginDirectory ?? process.env.MEMMY_BUNDLED_PLUGINS_DIR + ); + const bundledPreferences = bundledCatalog + ? await readBundledPluginPreferences( + memmyConfigPath, + bundledCatalog.managedPluginIds + ) + : {}; const services = createBackendServices({ appStateStore, agentAdapterRegistry, @@ -149,8 +179,61 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr scanPreferencesStore, accountChannel: options.accountChannel, memmyAgentAdminClient: options.memmyAgentAdminClient, - memmyAgentAdminBootstrapSecret: await readAgentGatewayBootstrapSecret(memmyConfigPath) + memmyAgentAdminBootstrapSecret: await readAgentGatewayBootstrapSecret(memmyConfigPath), + pluginRegistry: configuredPluginRegistry(process.env, bundledCatalog?.registry, (): Record => { + const cloudUuid = appStateStore.repositories.accountSession.getCloudUuid(); + return cloudUuid ? { authorization: `Bearer ${cloudUuid}` } : {}; + }), + trustedBundledPluginRoots: bundledCatalog ? [bundledCatalog.trustedArtifactRoot] : undefined }); + pluginService = services.plugins; + if (bundledCatalog) { + const failures = await suppressBundledPlugins({ + plugins: services.plugins, + releases: bundledCatalog.releases, + managedPluginIds: bundledCatalog.managedPluginIds, + enabledById: Object.fromEntries( + bundledCatalog.managedPluginIds.map((pluginId) => [ + pluginId, + bundledPreferences?.[pluginId]?.enabled !== false + ]) + ) + }); + for (const failure of failures) { + console.warn( + `Suppressed bundled plugin deactivation failed for ${failure.pluginId}: ${failure.message}` + ); + } + } + await services.plugins.restoreActive(); + if (bundledCatalog && bundledPreferences) { + const failures = await reconcileBundledPlugins({ + plugins: services.plugins, + releases: bundledCatalog.releases, + userUninstalledIds: new Set(appStateStore.repositories.plugins.listUserUninstalledIds()), + enabledById: Object.fromEntries( + bundledCatalog.releases.map((release) => [ + release.id, + bundledPreferences[release.id]?.enabled !== false + ]) + ) + }); + for (const failure of failures) { + console.warn(`Bundled plugin bootstrap failed for ${failure.pluginId}: ${failure.message}`); + } + } + // Runs on every launch so an entitled plugin missing after an upgrade is reinstalled. + const reconcileEntitlements = async () => { + const failures = await reconcileEntitledPlugins({ + plugins: services.plugins, + userUninstalledIds: new Set(appStateStore.repositories.plugins.listUserUninstalledIds()), + entitlements: appStateStore.repositories.accountSession.getEntitlements() + }); + for (const failure of failures) { + console.warn(`Entitled plugin reconciliation failed for ${failure.pluginId}: ${failure.message}`); + } + }; + await reconcileEntitlements(); const localToken = await permissionManager.getRuntimeToken(); const composioMcpToken = `mmt_${randomBytes(32).toString("base64url")}`; await removeLegacyKnowledgeCredentials(memmyConfigPath); @@ -169,7 +252,8 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr composioMcpToken, timeZone: configuredTimeZone, heartbeatIntervalMs: options.heartbeatIntervalMs, - scanProcess + scanProcess, + pluginCapabilitiesChanged: () => reloadAgentMcp(options.memmyAgentAdminClient) }); await server.listen({ host: "127.0.0.1", port: 0 }); @@ -185,6 +269,15 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr headers: { "x-memmy-mcp-token": composioMcpToken }, toolTimeout: 60 }); + await memmyConfigWriter.patchMcpServerConfig("plugins", { + type: "streamableHttp", + url: `http://127.0.0.1:${(address as AddressInfo).port}/mcp/plugins`, + headers: { "x-memmy-mcp-token": composioMcpToken }, + // Interaction capabilities can legitimately wait while the user reads a + // long card or leaves the app in the background. Non-interactive command + // work remains bounded by the plugin runtime's own timeout. + toolTimeout: 7 * 24 * 60 * 60 + }); const memmyAgentAdminClient = options.memmyAgentAdminClient; if (memmyAgentAdminClient && !options.skipAgentMcpReload) { // The bridge config must be written first, but MCP connections must not delay the first window. @@ -206,6 +299,7 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr }); await writeRuntimeConfigFile(runtimeConfig, options.runtimeConfigPath ?? resolveDefaultRuntimeConfigPath()); const boundServer = server; + const boundPluginService = services.plugins; return { runtimeConfig, getAppSettings() { @@ -215,17 +309,55 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr return appStateStore.repositories.bootstrap.recordLastLaunchMode(mode); }, async close() { - await boundServer.close(); - appStateStore.close(); + try { + await boundServer.close(); + } finally { + await boundPluginService.shutdown(); + appStateStore.close(); + } } }; } catch (error) { await server?.close().catch(() => undefined); + await pluginService?.shutdown(); appStateStore.close(); throw error; } } +function configuredPluginRegistry( + env: NodeJS.ProcessEnv, + bundledRegistry?: PluginRegistry, + authHeaders?: () => Record +): PluginRegistry | undefined { + const baseUrl = resolvePluginRegistryBaseUrl(env); + const remoteRegistry = baseUrl ? createHttpPluginRegistry({ baseUrl, authHeaders }) : undefined; + // Explicit remote distribution must never fall back to a locally bundled package. + if (env.MEMMY_PLUGIN_REGISTRY_URL?.trim()) return remoteRegistry; + return bundledRegistry ? createCompositePluginRegistry(bundledRegistry, remoteRegistry) : remoteRegistry; +} + +async function tryLoadBundledPluginCatalog(directory: string | undefined): Promise { + const normalized = directory?.trim(); + if (!normalized) return null; + try { + return await loadBundledPluginCatalog(normalized); + } catch (error) { + console.warn(`Bundled plugins are unavailable: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} + +async function reloadAgentMcp(client: MemmyAgentAdminClient | undefined): Promise { + if (!client) return; + try { + const result = await client.reloadMcpConfig(); + if (!result.ok) console.warn(`Agent MCP reload did not complete: ${result.message}`); + } catch (error) { + console.warn(`Agent MCP reload unavailable: ${error instanceof Error ? error.message : String(error)}`); + } +} + /** * Creates the default CloudClient. * diff --git a/App/backend/src/infrastructure/app-state-store/index.ts b/App/backend/src/infrastructure/app-state-store/index.ts index 0823d2b81..f81b356cd 100644 --- a/App/backend/src/infrastructure/app-state-store/index.ts +++ b/App/backend/src/infrastructure/app-state-store/index.ts @@ -12,6 +12,7 @@ import { createBootstrapRepository, type BootstrapRepository } from "./repositor import { createByokTokenUsageRepository, type ByokTokenUsageRepository } from "./repositories/byok-token-usage-repo.js"; import { createComposioMachineTokenRepository, type ComposioMachineTokenRepository } from "./repositories/composio-machine-token-repo.js"; import { createDeviceIdentityRepository, type DeviceIdentityRepository } from "./repositories/device-identity-repo.js"; +import { createPluginRepository, type PluginRepository } from "./repositories/plugin-repo.js"; import { finalizeDatabaseDesign } from "./schema-finalizer.js"; import { createSqliteSecretStore, type SecretStore } from "./secret-store.js"; @@ -47,6 +48,8 @@ export interface AppStateStore { byokTokenUsage: ByokTokenUsageRepository; /** Installation-scoped device identity. */ deviceIdentity: DeviceIdentityRepository; + /** Installed plugins. */ + plugins: PluginRepository; }; /** Secret store. */ secretStore: SecretStore; @@ -81,7 +84,8 @@ export function createAppStateStore(options: CreateAppStateStoreOptions = {}): A idempotency: createIdempotencyStore(db, { getActiveUuid }), composioMachineToken: createComposioMachineTokenRepository(secretStore), byokTokenUsage: createByokTokenUsageRepository(db), - deviceIdentity: createDeviceIdentityRepository(db) + deviceIdentity: createDeviceIdentityRepository(db), + plugins: createPluginRepository(db) }, secretStore, localDataStore, diff --git a/App/backend/src/infrastructure/app-state-store/migration-runner.ts b/App/backend/src/infrastructure/app-state-store/migration-runner.ts index 35c017612..d97fb0651 100644 --- a/App/backend/src/infrastructure/app-state-store/migration-runner.ts +++ b/App/backend/src/infrastructure/app-state-store/migration-runner.ts @@ -10,7 +10,8 @@ export interface MigrationResult { } const MIGRATION_ALIASES: Record = { - "0015-asr-model-config.sql": ["0015-asr-config.sql"] + "0015-asr-model-config.sql": ["0015-asr-config.sql"], + "0029-plugin-uninstall-preferences.sql": ["0030-plugin-uninstall-preferences.sql"] }; export function runMigrations( diff --git a/App/backend/src/infrastructure/app-state-store/migrations/0026-plugin-host.sql b/App/backend/src/infrastructure/app-state-store/migrations/0026-plugin-host.sql new file mode 100644 index 000000000..674593e2b --- /dev/null +++ b/App/backend/src/infrastructure/app-state-store/migrations/0026-plugin-host.sql @@ -0,0 +1,23 @@ +CREATE TABLE installed_plugins ( + id TEXT PRIMARY KEY, + version TEXT NOT NULL, + manifest_json TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ( + 'installed', + 'pending_approval', + 'enabling', + 'active', + 'disabling', + 'disabled', + 'failed' + )), + approved_permissions_json TEXT NOT NULL DEFAULT '[]', + config_json TEXT NOT NULL DEFAULT '{}', + artifact_hash TEXT, + root_path TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_installed_plugins_state ON installed_plugins(state); diff --git a/App/backend/src/infrastructure/app-state-store/migrations/0027-plugin-call-log.sql b/App/backend/src/infrastructure/app-state-store/migrations/0027-plugin-call-log.sql new file mode 100644 index 000000000..d28e1e49a --- /dev/null +++ b/App/backend/src/infrastructure/app-state-store/migrations/0027-plugin-call-log.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS plugin_call_logs ( + call_id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + plugin_version TEXT NOT NULL, + capability_id TEXT NOT NULL, + adapter_id TEXT NOT NULL, + duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0), + outcome TEXT NOT NULL CHECK (outcome IN ('success', 'error', 'interrupted')), + error_code TEXT, + called_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_plugin_call_logs_plugin_called_at + ON plugin_call_logs(plugin_id, called_at DESC); diff --git a/App/backend/src/infrastructure/app-state-store/migrations/0029-plugin-uninstall-preferences.sql b/App/backend/src/infrastructure/app-state-store/migrations/0029-plugin-uninstall-preferences.sql new file mode 100644 index 000000000..b749fd700 --- /dev/null +++ b/App/backend/src/infrastructure/app-state-store/migrations/0029-plugin-uninstall-preferences.sql @@ -0,0 +1,5 @@ +-- Keep explicit user removals after the installed plugin record is deleted. +CREATE TABLE plugin_uninstall_preferences ( + plugin_id TEXT PRIMARY KEY, + uninstalled_at TEXT NOT NULL +); 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 7977372f2..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 @@ -13,6 +13,8 @@ import type { SecretStore } from "../secret-store.js"; export interface AccountSessionRepository { get(): AccountSessionView; + /** Reads the cloud-issued grants held by the active account, e.g. `plugin:`. */ + getEntitlements(): string[]; /** Reads the explicit channel used to create the active login session. */ getAuthChannel(): AccountChannel | null; /** Reads the channel bound to a persisted cloud credential without activating it. */ @@ -37,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. */ @@ -84,6 +88,12 @@ export function createAccountSessionRepository(db: DatabaseSync, secretStore: Se }); }, + getEntitlements() { + const row = getActiveAccountRow(db); + if (!row?.user_id || !row.raw_profile_json) return []; + return readEntitlements(parseRawProfile(row.raw_profile_json)?.entitlements); + }, + getAuthChannel() { return resolveAccountAuthChannel(getActiveAccountRow(db)); }, @@ -184,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, @@ -409,6 +425,23 @@ function resolveLegacyAccountAuthChannel(row: AccountSessionRow | null): Account return hasEmail ? "email" : "phone"; } +/** + * Reads account entitlements out of the persisted raw profile. + * + * Entitlements ride along in raw_profile_json rather than a dedicated column so the cloud can add + * grants without a client migration, matching how every other unrecognised profile field is kept. + * + * @param value the raw entitlements field. + * @returns the deduplicated non-empty grants; returns an empty array when absent or malformed. + */ +function readEntitlements(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const entries = value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0); + return [...new Set(entries)]; +} + /** * Reads a string from a raw profile field. * diff --git a/App/backend/src/infrastructure/app-state-store/repositories/plugin-repo.ts b/App/backend/src/infrastructure/app-state-store/repositories/plugin-repo.ts new file mode 100644 index 000000000..d7947e08e --- /dev/null +++ b/App/backend/src/infrastructure/app-state-store/repositories/plugin-repo.ts @@ -0,0 +1,226 @@ +/** Installed plugin persistence. */ +import { + InstalledPluginSchema, + PluginManifestSchema, + PluginPermissionSchema, + PluginStateSchema, + type InstalledPlugin, + type PluginManifest, + type PluginPermission, + type PluginState +} from "@memmy/local-api-contracts"; +import type { DatabaseSync } from "node:sqlite"; +import { z } from "zod"; + +const ConfigSchema = z.record(z.string(), z.unknown()); +const PermissionsSchema = z.array(PluginPermissionSchema); + +export interface PluginRecord extends InstalledPlugin { + artifactHash: string | null; + rootPath: string | null; +} + +export interface SavePluginInput { + manifest: PluginManifest; + state: PluginState; + artifactHash?: string | null; + rootPath?: string | null; +} + +export interface PluginRepository { + list(): PluginRecord[]; + /** Machine-level removals that bundled startup must not undo. */ + listUserUninstalledIds(): string[]; + get(id: string): PluginRecord | null; + save(input: SavePluginInput): PluginRecord; + setState(id: string, state: PluginState, lastError?: string | null): PluginRecord; + setConfig(id: string, config: Record): PluginRecord; + setApprovedPermissions(id: string, permissions: PluginPermission[]): PluginRecord; + recordCall(input: PluginCallLogInput): void; + /** Removes the installation and atomically remembers the user's uninstall. */ + delete(id: string): boolean; +} + +export interface PluginCallLogInput { + callId: string; + pluginId: string; + pluginVersion: string; + capabilityId: string; + adapterId: string; + durationMs: number; + outcome: "success" | "error" | "interrupted"; + errorCode?: string | null; +} + +interface PluginRow { + id: string; + version: string; + manifest_json: string; + state: string; + approved_permissions_json: string; + config_json: string; + artifact_hash: string | null; + root_path: string | null; + last_error: string | null; + created_at: string; + updated_at: string; +} + +export function createPluginRepository(db: DatabaseSync): PluginRepository { + const getRequired = (id: string): PluginRecord => { + const plugin = readPlugin(db, id); + if (!plugin) throw Object.assign(new Error(`Plugin not found: ${id}`), { code: "not_found" as const }); + return plugin; + }; + + return { + list() { + const rows = db.prepare(`${SELECT_PLUGIN} ORDER BY id`).all() as unknown as PluginRow[]; + return rows.map(toPluginRecord); + }, + + listUserUninstalledIds() { + const rows = db.prepare("SELECT plugin_id FROM plugin_uninstall_preferences ORDER BY plugin_id") + .all() as { plugin_id: string }[]; + return rows.map((row) => row.plugin_id); + }, + + get(id) { + return readPlugin(db, id); + }, + + save(input) { + const manifest = PluginManifestSchema.parse(input.manifest); + const state = PluginStateSchema.parse(input.state); + const now = new Date().toISOString(); + return withPluginTransaction(db, () => { + db.prepare( + `INSERT INTO installed_plugins ( + id, version, manifest_json, state, artifact_hash, root_path, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + version = excluded.version, + manifest_json = excluded.manifest_json, + state = excluded.state, + artifact_hash = excluded.artifact_hash, + root_path = excluded.root_path, + last_error = NULL, + updated_at = excluded.updated_at` + ).run( + manifest.id, + manifest.version, + JSON.stringify(manifest), + state, + input.artifactHash ?? null, + input.rootPath ?? null, + now, + now + ); + // A successful install restores the user's default installation choice. + db.prepare("DELETE FROM plugin_uninstall_preferences WHERE plugin_id = ?").run(manifest.id); + return getRequired(manifest.id); + }); + }, + + setState(id, state, lastError = null) { + const result = db.prepare( + "UPDATE installed_plugins SET state = ?, last_error = ?, updated_at = ? WHERE id = ?" + ).run(PluginStateSchema.parse(state), lastError, new Date().toISOString(), id); + if (result.changes === 0) getRequired(id); + return getRequired(id); + }, + + setConfig(id, config) { + const result = db.prepare( + "UPDATE installed_plugins SET config_json = ?, updated_at = ? WHERE id = ?" + ).run(JSON.stringify(ConfigSchema.parse(config)), new Date().toISOString(), id); + if (result.changes === 0) getRequired(id); + return getRequired(id); + }, + + setApprovedPermissions(id, permissions) { + const result = db.prepare( + "UPDATE installed_plugins SET approved_permissions_json = ?, updated_at = ? WHERE id = ?" + ).run(JSON.stringify(PermissionsSchema.parse(permissions)), new Date().toISOString(), id); + if (result.changes === 0) getRequired(id); + return getRequired(id); + }, + + recordCall(input) { + db.prepare(`INSERT INTO plugin_call_logs ( + call_id, plugin_id, plugin_version, capability_id, adapter_id, + duration_ms, outcome, error_code, called_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + input.callId, + input.pluginId, + input.pluginVersion, + input.capabilityId, + input.adapterId, + Math.max(0, Math.trunc(input.durationMs)), + input.outcome, + input.errorCode ?? null, + new Date().toISOString() + ); + }, + + delete(id) { + return withPluginTransaction(db, () => { + const deleted = db.prepare("DELETE FROM installed_plugins WHERE id = ?").run(id).changes > 0; + if (deleted) { + db.prepare(`INSERT INTO plugin_uninstall_preferences (plugin_id, uninstalled_at) + VALUES (?, ?) ON CONFLICT(plugin_id) DO UPDATE SET uninstalled_at = excluded.uninstalled_at`) + .run(id, new Date().toISOString()); + } + return deleted; + }); + } + }; +} + +function withPluginTransaction(db: DatabaseSync, operation: () => T): T { + db.exec("SAVEPOINT plugin_lifecycle"); + try { + const result = operation(); + db.exec("RELEASE SAVEPOINT plugin_lifecycle"); + return result; + } catch (error) { + db.exec("ROLLBACK TO SAVEPOINT plugin_lifecycle"); + db.exec("RELEASE SAVEPOINT plugin_lifecycle"); + throw error; + } +} + +const SELECT_PLUGIN = `SELECT + id, + version, + manifest_json, + state, + approved_permissions_json, + config_json, + artifact_hash, + root_path, + last_error, + created_at, + updated_at +FROM installed_plugins`; + +function readPlugin(db: DatabaseSync, id: string): PluginRecord | null { + const row = db.prepare(`${SELECT_PLUGIN} WHERE id = ?`).get(id) as PluginRow | undefined; + return row ? toPluginRecord(row) : null; +} + +function toPluginRecord(row: PluginRow): PluginRecord { + const installed = InstalledPluginSchema.parse({ + id: row.id, + version: row.version, + manifest: PluginManifestSchema.parse(JSON.parse(row.manifest_json)), + state: row.state, + approvedPermissions: PermissionsSchema.parse(JSON.parse(row.approved_permissions_json)), + config: ConfigSchema.parse(JSON.parse(row.config_json)), + lastError: row.last_error, + createdAt: row.created_at, + updatedAt: row.updated_at + }); + return { ...installed, artifactHash: row.artifact_hash, rootPath: row.root_path }; +} 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/infrastructure/app-state-store/tests/index.test.ts b/App/backend/src/infrastructure/app-state-store/tests/index.test.ts index c46ac36c9..38737b048 100644 --- a/App/backend/src/infrastructure/app-state-store/tests/index.test.ts +++ b/App/backend/src/infrastructure/app-state-store/tests/index.test.ts @@ -131,8 +131,8 @@ describe("app state store migrations", () => { expect(settings.menuBarIconEnabled).toBe(true); expect(settings.stopMemoryServiceOnExit).toBe(false); expect(agentSources).toEqual([]); - expect(firstMigrationCount).toBe(35); - expect(secondMigrationCount).toBe(35); + expect(firstMigrationCount).toBe(38); + expect(secondMigrationCount).toBe(38); }); it("preserves the authenticated account when upgrading the legacy 0007 database", () => { @@ -1543,6 +1543,33 @@ describe("app state store migrations", () => { expect(historicalMigration).toBeUndefined(); }); + it("recognizes the earlier plugin removal migration filename", () => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-app-state-")); + const databasePath = join(tempDir, "app.sqlite"); + const initialStore = createAppStateStore({ databasePath }); + + initialStore.db + .prepare("UPDATE _migrations SET name = ? WHERE name = ?") + .run("0030-plugin-uninstall-preferences.sql", "0029-plugin-uninstall-preferences.sql"); + initialStore.close(); + + const recoveredStore = createAppStateStore({ databasePath }); + const currentMigration = recoveredStore.db + .prepare("SELECT name FROM _migrations WHERE name = ?") + .get("0029-plugin-uninstall-preferences.sql"); + const historicalMigration = recoveredStore.db + .prepare("SELECT name FROM _migrations WHERE name = ?") + .get("0030-plugin-uninstall-preferences.sql"); + const table = recoveredStore.db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'plugin_uninstall_preferences'") + .get(); + recoveredStore.close(); + + expect(currentMigration).toEqual({ name: "0029-plugin-uninstall-preferences.sql" }); + expect(historicalMigration).toBeUndefined(); + expect(table).toEqual({ name: "plugin_uninstall_preferences" }); + }); + it("migrates the existing account scan permission into the installation scope", () => { tempDir = mkdtempSync(join(tmpdir(), "memmy-app-state-")); const databasePath = join(tempDir, "app.sqlite"); diff --git a/App/backend/src/infrastructure/app-state-store/tests/plugin-repo.test.ts b/App/backend/src/infrastructure/app-state-store/tests/plugin-repo.test.ts new file mode 100644 index 000000000..350b8cd39 --- /dev/null +++ b/App/backend/src/infrastructure/app-state-store/tests/plugin-repo.test.ts @@ -0,0 +1,73 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createAppStateStore, type AppStateStore } from "../index.js"; + +let root: string | undefined; +let store: AppStateStore | undefined; + +afterEach(() => { + store?.close(); + store = undefined; + if (root) rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +describe("PluginRepository", () => { + it("persists lifecycle state, config, and approved permissions", () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-repo-")); + store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + const repository = store.repositories.plugins; + const manifest = { + apiVersion: "memmy/v1" as const, + id: "com.example.review", + name: "Review", + version: "1.0.0", + runtime: { adapter: "http" as const, config: { baseUrl: "https://plugin.example" } }, + capabilities: [{ + id: "run", + name: "Run review", + description: "Runs a review", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "job" as const + }], + permissions: [{ type: "network" as const, hosts: ["plugin.example"] }] + }; + + repository.save({ manifest, state: "pending_approval", artifactHash: "abc" }); + repository.setApprovedPermissions(manifest.id, manifest.permissions); + repository.setConfig(manifest.id, { database: "crossref" }); + repository.setState(manifest.id, "active"); + + store.close(); + store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + expect(store.repositories.plugins.get(manifest.id)).toMatchObject({ + id: manifest.id, + state: "active", + artifactHash: "abc", + config: { database: "crossref" }, + approvedPermissions: manifest.permissions + }); + + store.repositories.plugins.recordCall({ + callId: "call-1", + pluginId: manifest.id, + pluginVersion: manifest.version, + capabilityId: "run", + adapterId: "http", + durationMs: 12.8, + outcome: "error", + errorCode: "plugin_timeout" + }); + expect(store.db.prepare("SELECT duration_ms, outcome, error_code FROM plugin_call_logs WHERE call_id = ?") + .get("call-1")).toEqual({ duration_ms: 12, outcome: "error", error_code: "plugin_timeout" }); + }); + + it("fails closed when a plugin does not exist", () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-repo-")); + store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + expect(() => store?.repositories.plugins.setState("missing", "active")).toThrow(/Plugin not found/); + }); +}); diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index d85c7e315..a55d98ad9 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -51,6 +51,43 @@ const ACCOUNT_MODELS = { type AccountCapability = keyof typeof ACCOUNT_MODELS; type AccountPresetIds = Record; +export type BundledPluginPreferences = Record; + +/** + * Reads explicit enablement for first-party bundled plugins. + * + * Missing entries deliberately default to enabled in the bootstrap service. + * A malformed file returns null so startup preserves the previous plugin state. + */ +export async function readBundledPluginPreferences( + configPath: string, + pluginIds: readonly string[] +): Promise { + let root: unknown; + try { + root = YAML.parse(await readFile(configPath, "utf8")) ?? {}; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return {}; + console.warn(`Bundled plugin preferences could not be read: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + if (!root || typeof root !== "object" || Array.isArray(root)) return null; + const plugins = (root as Record).plugins; + if (plugins === undefined) return {}; + if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) return null; + + const preferences: BundledPluginPreferences = {}; + for (const pluginId of pluginIds) { + const entry = (plugins as Record)[pluginId]; + if (entry === undefined) continue; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; + const enabled = (entry as Record).enabled; + if (typeof enabled !== "boolean") return null; + preferences[pluginId] = { enabled }; + } + return preferences; +} + /** Handles resolve memmy account api base. */ export function resolveMemmyAccountApiBase(): string { return `${resolveCloudServiceBaseUrl(process.env.MEMMY_CLOUD_SERVICE)}/api/agentExternal/v1`; diff --git a/App/backend/src/infrastructure/memmy-config/tests/index.test.ts b/App/backend/src/infrastructure/memmy-config/tests/index.test.ts index b0ba92ea0..fb3af2778 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/index.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/index.test.ts @@ -10,6 +10,7 @@ import { patchChannelConfigInMemmyConfig, patchMcpServerConfigInMemmyConfig, readAgentGatewayBootstrapSecret, + readBundledPluginPreferences, readConfiguredAgentTimeZone, readRuntimeMemmyConfigState, resolveDefaultMemmyConfigPath, @@ -63,6 +64,21 @@ describe("memmy runtime config current contract", () => { await expect(readRuntimeMemmyConfigState(file("agents: ["))).resolves.toMatchObject({ status: "invalid_yaml" }); }); + it("reads only explicit bundled plugin enablement and preserves state on invalid config", async () => { + await expect(readBundledPluginPreferences(file({}), ["literature-review"])) + .resolves.toEqual({}); + await expect(readBundledPluginPreferences(file({ + plugins: { "literature-review": { enabled: false } } + }), ["literature-review"])).resolves.toEqual({ + "literature-review": { enabled: false } + }); + await expect(readBundledPluginPreferences(file("plugins:\n literature-review:\n enabled: maybe\n"), [ + "literature-review" + ])).resolves.toBeNull(); + await expect(readBundledPluginPreferences(file("plugins: ["), ["literature-review"])) + .resolves.toBeNull(); + }); + it("derives the canonical BYOK context and exact Provider endpoint snapshot", async () => { const target = file(currentByokCatalog()); await expect(readRuntimeMemmyConfigState(target)).resolves.toMatchObject({ diff --git a/App/backend/src/services/account-service.ts b/App/backend/src/services/account-service.ts index 84178908b..0631ac155 100644 --- a/App/backend/src/services/account-service.ts +++ b/App/backend/src/services/account-service.ts @@ -65,6 +65,8 @@ export interface CreateAccountServiceOptions { now?: () => Date; /** Verification channel supported by the current desktop package. */ accountChannel?: AccountChannel; + /** Invoked after the cloud profile is refreshed so entitlement-gated features can be reconciled. */ + onAccountGrantsRefreshed?: () => Promise; } /** Creates create account service. */ @@ -179,6 +181,7 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco } const updated = options.accountSessionRepository.upsert({ + rawProfileMode: "merge", profile: { ...session.profile, nickname: input.nickname, @@ -234,6 +237,7 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco accountSessionRepository: options.accountSessionRepository, session, cloudUuid: cloudUuid ?? undefined, + onGrantsRefreshed: options.onAccountGrantsRefreshed, onAuthenticationInvalid: () => clearLocalAccountState( options, session.authenticated ? session.profile.userId : undefined, @@ -289,7 +293,8 @@ async function finalizeCloudLogin( cloudClient: options.cloudClient, accountSessionRepository: options.accountSessionRepository, session, - cloudUuid: loginResult.uuid + cloudUuid: loginResult.uuid, + onGrantsRefreshed: options.onAccountGrantsRefreshed }); return AccountLoginResultViewSchema.parse({ session: refreshedSession, @@ -329,6 +334,9 @@ async function clearLocalAccountState( options.accountSessionRepository.clear(); } await reloadMemoryConfigIfNeeded(projection, options); + await options.onAccountGrantsRefreshed?.().catch((error: unknown) => { + console.warn(`Account grant reconciliation failed: ${String(error)}`); + }); } /** Handles refresh cloud guide state. */ @@ -337,6 +345,7 @@ async function refreshCloudGuideState(input: { accountSessionRepository: AccountSessionRepository; session: AccountSessionView; cloudUuid?: string; + onGrantsRefreshed?: () => Promise; onAuthenticationInvalid?: () => Promise; }): Promise { if (!input.session.authenticated) { @@ -357,12 +366,17 @@ async function refreshCloudGuideState(input: { } throw error; } - return AccountSessionViewSchema.parse( + const refreshed = AccountSessionViewSchema.parse( input.accountSessionRepository.upsert({ profile: toSessionProfileInput(cloudProfile), isNewUser: input.session.isNewUser }) ); + // Entitlement-gated features reconcile in the background; a failure here must not fail the login. + await input.onGrantsRefreshed?.().catch((error: unknown) => { + console.warn(`Account grant reconciliation failed: ${error instanceof Error ? error.message : String(error)}`); + }); + return refreshed; } function isUnauthorized(error: unknown): boolean { diff --git a/App/backend/src/services/asr-service.ts b/App/backend/src/services/asr-service.ts index da7f115c5..8996c0989 100644 --- a/App/backend/src/services/asr-service.ts +++ b/App/backend/src/services/asr-service.ts @@ -84,7 +84,8 @@ async function transcribeWithAccount( uuid, audioBase64: input.audioBase64, mimeType: input.mimeType, - durationMs: input.durationMs + durationMs: input.durationMs, + diarization: input.diarization }); } catch (error) { throw withActualModelContext(error, context); @@ -95,7 +96,8 @@ async function transcribeWithAccount( modelId: context.model, provider: context.provider, source: context.source, - transcribedAt: now() + transcribedAt: now(), + ...(result.segments?.length ? { segments: result.segments } : {}) }); } diff --git a/App/backend/src/services/asr-stream-service.ts b/App/backend/src/services/asr-stream-service.ts new file mode 100644 index 000000000..7857d99f7 --- /dev/null +++ b/App/backend/src/services/asr-stream-service.ts @@ -0,0 +1,211 @@ +/** + * Live ASR stream relay: renderer ⇄ local API ⇄ Memmy Cloud. + * + * The renderer cannot talk to the Cloud's `/agentAsr/stream` directly — it + * holds neither the account credential nor the Cloud origin, and the Cloud's + * handshake wants the account bearer token, not the local runtime token. So + * the local API accepts the renderer's WebSocket, authenticates it with the + * runtime token, opens a second WebSocket to the Cloud with the account + * credential, and pipes frames both ways without inspecting the audio. + * + * Text frames (control and events) are validated at the boundary; binary + * frames are forwarded as-is. If either side closes, the other is closed with + * a matching status so a dropped microphone never leaves an upstream task + * hanging on the Cloud. + */ +import { AsrStreamControlSchema, AsrStreamEventSchema } from "@memmy/local-api-contracts"; +import type { IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; +import WebSocket, { WebSocketServer } from "ws"; +import type { AccountSessionRepository } from "../infrastructure/app-state-store/repositories/account-session-repo.js"; +import type { BootstrapRepository } from "../infrastructure/app-state-store/repositories/bootstrap-repo.js"; +import type { AppSettingsDto } from "@memmy/local-api-contracts"; + +export interface AsrStreamService { + /** + * Handles an HTTP upgrade for `/api/asr/stream`. + * + * Authentication has already happened by the time this is called; the + * service only has to decide whether the account can stream and then + * bridge the two sockets. + */ + handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void; + /** Closes every open bridge; used on shutdown. */ + close(): Promise; +} + +export interface CreateAsrStreamServiceOptions { + bootstrapRepository: Pick | { getAppSettings(): Pick }; + accountSessionRepository: Pick; + /** Cloud API base URL, e.g. `https://test-api.memmy.cn` (no `/api` suffix). */ + cloudBaseUrl: string; + /** Overrides the upstream socket constructor; tests use it. */ + connectUpstream?: (url: string, headers: Record) => WebSocket; +} + +/** + * Every existing Cloud call goes through `/api/...` (see + * `http-cloud-client.ts`'s `/api/agentAsr/transcriptions`, + * `/api/agentUser/login`, etc.) — an external gateway in front of the Java + * service adds that prefix; the service's own Spring mapping is the bare + * `/agentAsr/...`. This constant is the public path, so it needs the prefix + * even though `AgentAsrWebSocketConfig.STREAM_PATH` on the Java side does not. + */ +const CLOUD_STREAM_PATH = "/api/agentAsr/stream"; +const CLOSE_NORMAL = 1000; +const CLOSE_POLICY = 1008; +const CLOSE_UPSTREAM_GONE = 1011; + +/** + * Creates the live ASR stream relay. + * + * @param options Repositories for mode/credential lookup and the Cloud origin. + * @returns The relay service. + */ +export function createAsrStreamService(options: CreateAsrStreamServiceOptions): AsrStreamService { + const server = new WebSocketServer({ noServer: true }); + const bridges = new Set(); + const connectUpstream = options.connectUpstream ?? ((url, headers) => new WebSocket(url, { headers })); + + return { + handleUpgrade(request, socket, head) { + server.handleUpgrade(request, socket, head, (downstream) => { + bridges.add(downstream); + downstream.once("close", () => bridges.delete(downstream)); + bridge(downstream, options, connectUpstream); + }); + }, + + async close() { + for (const downstream of bridges) { + downstream.close(CLOSE_NORMAL, "shutdown"); + } + bridges.clear(); + await new Promise((resolve) => server.close(() => resolve())); + } + }; +} + +/** + * Turns the Cloud HTTP base URL into its WebSocket stream URL. + * + * @param cloudBaseUrl The Cloud API base, with or without a trailing slash. + * @returns The `wss://` (or `ws://` for loopback stubs) stream endpoint. + */ +export function toCloudStreamUrl(cloudBaseUrl: string): string { + const url = new URL(cloudBaseUrl.replace(/\/+$/, "") + CLOUD_STREAM_PATH); + url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; + return url.toString(); +} + +function bridge( + downstream: WebSocket, + options: CreateAsrStreamServiceOptions, + connectUpstream: NonNullable +): void { + const userMode = options.bootstrapRepository.getAppSettings().userMode; + if (userMode !== "account") { + // BYOK has no Cloud relay to lean on, and there is no BYOK streaming + // transport yet; the segmented HTTP path is what that mode gets. + sendEvent(downstream, { type: "error", message: "Live transcription requires account mode" }); + downstream.close(CLOSE_POLICY, "account mode required"); + return; + } + const uuid = options.accountSessionRepository.getCloudUuid(); + if (!uuid) { + sendEvent(downstream, { type: "error", message: "Cloud account is not authenticated" }); + downstream.close(CLOSE_POLICY, "unauthenticated"); + return; + } + + const upstream = connectUpstream(toCloudStreamUrl(options.cloudBaseUrl), { + authorization: `Bearer ${uuid}`, + "x-agent-region": process.env.MEMMY_APP_EDITION?.trim().toLowerCase() === "intl" ? "intl" : "cn" + }); + // Frames that arrive before the upstream handshake completes are held, in + // order, so the first block of speech is not lost to a race. + const queued: Array<{ data: Buffer; binary: boolean }> = []; + let upstreamOpen = false; + + const forwardUp = (data: Buffer, binary: boolean) => { + if (upstreamOpen) upstream.send(data, { binary }); + else queued.push({ data, binary }); + }; + + upstream.on("open", () => { + upstreamOpen = true; + for (const frame of queued) upstream.send(frame.data, { binary: frame.binary }); + queued.length = 0; + }); + + downstream.on("message", (raw, isBinary) => { + const data = toBuffer(raw); + if (isBinary) { + forwardUp(data, true); + return; + } + // Only well-formed control frames go up; anything else is the renderer's + // bug and is answered locally rather than handed to the Cloud. + const parsed = AsrStreamControlSchema.safeParse(safeJson(data.toString("utf8"))); + if (!parsed.success) { + sendEvent(downstream, { type: "error", message: "invalid control frame" }); + return; + } + forwardUp(Buffer.from(JSON.stringify(parsed.data)), false); + }); + + upstream.on("message", (raw, isBinary) => { + if (isBinary) return; + const parsed = AsrStreamEventSchema.safeParse(safeJson(toBuffer(raw).toString("utf8"))); + // An event the contract does not know is dropped rather than relayed: the + // renderer's parser would reject it anyway, and one bad frame must not end + // the stream. + if (parsed.success) sendEvent(downstream, parsed.data); + }); + + downstream.on("close", (code, reason) => { + if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) { + upstream.close(normalizeCloseCode(code), reason.toString()); + } + }); + upstream.on("close", (code, reason) => { + if (downstream.readyState === WebSocket.OPEN) { + downstream.close(normalizeCloseCode(code), reason.toString()); + } + }); + upstream.on("error", (error) => { + if (downstream.readyState === WebSocket.OPEN) { + sendEvent(downstream, { type: "error", message: `upstream: ${error.message}` }); + downstream.close(CLOSE_UPSTREAM_GONE, "upstream error"); + } + }); + downstream.on("error", () => { + if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) { + upstream.close(CLOSE_NORMAL, "downstream error"); + } + }); +} + +function sendEvent(socket: WebSocket, event: unknown): void { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(event)); +} + +function toBuffer(raw: WebSocket.RawData): Buffer { + if (Buffer.isBuffer(raw)) return raw; + if (Array.isArray(raw)) return Buffer.concat(raw); + return Buffer.from(raw); +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** Only codes the RFC allows an endpoint to send can be echoed to the other side. */ +function normalizeCloseCode(code: number): number { + if (code === 1005 || code === 1006 || code === 1015 || code < 1000 || code > 4999) return CLOSE_NORMAL; + return code; +} diff --git a/App/backend/src/services/bundled-plugin-bootstrap-service.ts b/App/backend/src/services/bundled-plugin-bootstrap-service.ts new file mode 100644 index 000000000..c11b9b86b --- /dev/null +++ b/App/backend/src/services/bundled-plugin-bootstrap-service.ts @@ -0,0 +1,76 @@ +/** Idempotent startup reconciliation for immutable first-party desktop plugins. */ +import type { BundledPluginRelease } from "../adapters/outbound/plugin-registry/index.js"; +import type { PluginService } from "./plugin-service.js"; + +export interface BundledPluginBootstrapFailure { + pluginId: string; + message: string; +} + +export interface ReconcileBundledPluginsOptions { + plugins: PluginService; + releases: readonly BundledPluginRelease[]; + enabledById: Readonly>; + userUninstalledIds?: ReadonlySet; +} + +export interface SuppressBundledPluginsOptions extends ReconcileBundledPluginsOptions { + managedPluginIds: readonly string[]; +} + +/** + * Deactivates persisted bundled plugins that the current distribution omitted + * or explicitly disabled, while preserving their artifacts and task data. + */ +export async function suppressBundledPlugins( + options: SuppressBundledPluginsOptions +): Promise { + const releaseIds = new Set(options.releases.map((release) => release.id)); + const failures: BundledPluginBootstrapFailure[] = []; + for (const pluginId of options.managedPluginIds) { + if (releaseIds.has(pluginId) && options.enabledById[pluginId] !== false) continue; + const installed = options.plugins.list().find((plugin) => plugin.id === pluginId); + if (!installed || !["active", "enabling", "failed"].includes(installed.state)) continue; + try { + await options.plugins.disable(pluginId); + } catch (error) { + failures.push({ + pluginId, + message: error instanceof Error ? error.message : String(error) + }); + } + } + return failures; +} + +/** + * Installs missing trusted bundled releases, grants their fixed declared + * permissions, and applies the config.yaml desired state without deleting data + * or reinstalling plugins the user explicitly removed. + */ +export async function reconcileBundledPlugins( + options: ReconcileBundledPluginsOptions +): Promise { + const failures: BundledPluginBootstrapFailure[] = []; + for (const release of options.releases) { + try { + const existing = options.plugins.list().find((plugin) => plugin.id === release.id); + if (existing?.update) continue; + if (!existing && options.userUninstalledIds?.has(release.id)) continue; + let plugin = existing ?? await options.plugins.install(release.id, release.version); + + plugin = await options.plugins.approvePermissions(release.id, plugin.manifest.permissions); + if (options.enabledById[release.id] !== false) { + await options.plugins.enable(release.id); + } else { + await options.plugins.disable(release.id); + } + } catch (error) { + failures.push({ + pluginId: release.id, + message: error instanceof Error ? error.message : String(error) + }); + } + } + return failures; +} diff --git a/App/backend/src/services/error-envelope.ts b/App/backend/src/services/error-envelope.ts index f4f61084b..e1ee7a2c3 100644 --- a/App/backend/src/services/error-envelope.ts +++ b/App/backend/src/services/error-envelope.ts @@ -22,7 +22,17 @@ export const API_ERROR_CODES = [ "toolkit_unsupported", "model_config_changed", "config_write_busy", - "account_model_preset_conflict" + "account_model_preset_conflict", + "plugin_invalid", + "plugin_incompatible", + "plugin_adapter_unsupported", + "plugin_permission_denied", + "plugin_entitlement_required", + "plugin_activation_failed", + "plugin_runtime_error", + "plugin_timeout", + "plugin_unavailable", + "plugin_interaction_invalid" ] as const; export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; @@ -46,7 +56,17 @@ export const HTTP_STATUS_BY_CODE: Readonly> = Objec toolkit_unsupported: 400, model_config_changed: 409, config_write_busy: 409, - account_model_preset_conflict: 409 + account_model_preset_conflict: 409, + plugin_invalid: 400, + 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, + plugin_unavailable: 409, + plugin_interaction_invalid: 409 }); export interface ApiError extends Error { diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index 65f47e091..fa9e1bf65 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -1,5 +1,8 @@ +import { createPluginSkillContentService, type PluginSkillContentService } from "./plugin-skill-content-service.js"; import type { AccountChannel } from "@memmy/local-api-contracts"; -import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +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"; @@ -14,6 +17,17 @@ import type { MemmyAgentAdminClient } from "../adapters/outbound/memmy-agent-adm import type { SkillTargetRegistry } from "../adapters/outbound/skill-writer/target-registry.js"; import type { CloudClient } from "../adapters/outbound/cloud-client/index.js"; import type { MemoryClient } from "../adapters/outbound/memory-client/index.js"; +import { + createCommandPluginAdapter, + createHttpPluginAdapter, + createMcpPluginAdapter, + createPluginRuntimeHost, + PluginAdapterRegistry, + type PluginHostServiceInvoker +} from "../adapters/outbound/plugin-runtime/index.js"; +import { createPluginArtifactManager, type PluginArtifactManager } from "../adapters/outbound/plugin-artifact/index.js"; +import { createPluginSkillManager } from "../adapters/outbound/plugin-skill/index.js"; +import type { PluginRegistry } from "../adapters/outbound/plugin-registry/index.js"; import type { PermissionManager } from "../permission/index.js"; import { createAgentSourceLifecycleAnalytics, @@ -28,6 +42,7 @@ import { createBuiltinSkillTargetRegistry } from "./builtin-skill-target-registr import { createAppConfigService, type AppConfigService } from "./app-config-service.js"; import { createAccountService, type AccountService } from "./account-service.js"; import { createAsrService, type AsrService } from "./asr-service.js"; +import { createAsrStreamService, type AsrStreamService } from "./asr-stream-service.js"; import { createTokenQuotaService, type TokenQuotaService } from "./token-quota-service.js"; import { createByokTokenUsageService, @@ -58,6 +73,14 @@ import { type SkillDistributionService } from "./skill-distribution-service.js"; import { createTurnService, type TurnService } from "./turn-service.js"; +import { createPluginService, type PluginRuntimeHost, type PluginService } from "./plugin-service.js"; +import { createPluginLocalArtifactService } from "./plugin-local-artifact-service.js"; +import { createPluginModelInferenceService } from "./plugin-model-inference-service.js"; +import { entitledPluginIds, 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"; export interface BackendServices { memoryClient: MemoryClient; @@ -82,8 +105,13 @@ export interface BackendServices { byokTokenUsage: ByokTokenUsageService; /** Asr. */ asr: AsrService; + /** Live ASR stream relay to the Cloud. */ + asrStream: AsrStreamService; /** Token quota. */ tokenQuota: TokenQuotaService; + /** Third-party plugins. */ + plugins: PluginService; + pluginSkillContent?: PluginSkillContentService; } export interface CreateBackendServicesOptions { @@ -98,6 +126,15 @@ export interface CreateBackendServicesOptions { skillDistributionService?: SkillDistributionService; skillTargetRegistry?: SkillTargetRegistry; progressBus?: ProgressBus; + pluginRegistry?: PluginRegistry; + pluginArtifactManager?: PluginArtifactManager; + /** Canonical resource roots trusted to provide immutable bundled plugin archives. */ + trustedBundledPluginRoots?: readonly string[]; + pluginRuntimeHost?: PluginRuntimeHost; + /** Optional Host-service dispatcher, primarily for embedding and tests. */ + pluginHostServices?: PluginHostServiceInvoker; + /** Exact hosts local command plugins may request. Defaults to MEMMY_COMMAND_PLUGIN_NETWORK_ALLOWLIST. */ + commandPluginNetworkAllowlist?: readonly string[]; /** Memmy config writer. */ memmyConfigWriter?: MemmyConfigWriter; /** Memmy config path. */ @@ -111,8 +148,128 @@ export interface CreateBackendServicesOptions { scanPreferencesStore?: ScanPreferencesStore; } +/** Exact network surface required by the first-party literature-review Providers. */ +export const DEFAULT_COMMAND_PLUGIN_NETWORK_ALLOWLIST = [ + "export.arxiv.org", + "arxiv.org", + "eutils.ncbi.nlm.nih.gov", + "pmc.ncbi.nlm.nih.gov", + "api.openalex.org", + "api.crossref.org" +] as const; + export function createBackendServices(options: CreateBackendServicesOptions): BackendServices { const progressBus = options.progressBus ?? createProgressBus(); + const memmyConfigWriter = options.memmyConfigWriter ?? createUnavailableMemmyConfigWriter(); + const accountSessionRepository = options.appStateStore.repositories.accountSession; + const pluginRepository = options.appStateStore.repositories.plugins; + const isEntitlementGranted = (entitlement: string) => accountSessionRepository.getEntitlements().includes(entitlement); + /** Lets the plugin registry gate entitlement-restricted releases and downloads. */ + const pluginRegistryAuthHeaders = (): Record => { + const cloudUuid = accountSessionRepository.getCloudUuid(); + return cloudUuid ? { authorization: `Bearer ${cloudUuid}` } : {}; + }; + const asrService = createAsrService({ + bootstrapRepository: options.appStateStore.repositories.bootstrap, + accountSessionRepository: options.appStateStore.repositories.accountSession, + memmyConfigWriter, + cloudClient: options.cloudClient + }); + const asrStreamService = createAsrStreamService({ + bootstrapRepository: options.appStateStore.repositories.bootstrap, + accountSessionRepository: options.appStateStore.repositories.accountSession, + cloudBaseUrl: resolveCloudClientConfig(process.env).baseUrl + }); + const agentWorkspace = resolveAgentWorkspace(process.env); + const uploadedMediaRoot = join(resolveAgentDataRoot(process.env), "media"); + // Plugins may read user-provided inputs anywhere in the default workspace, + // including recordings and future Host-owned input folders. Writes remain + // confined to plugin-data and the artifact-host publishing service. + const pluginFileInputRoots = [uploadedMediaRoot, agentWorkspace]; + // ASR only needs audio upload locations; do not let it turn an arbitrary + // workspace file into a base64 cloud request merely because it has a path. + const pluginAudioRoots = [uploadedMediaRoot, join(agentWorkspace, "recordings")]; + const pluginSkillContent = createPluginSkillContentService({ + baseUrl: resolvePluginRegistryBaseUrl(process.env) ?? resolveCloudClientConfig(process.env).baseUrl, + getPlugin: (id) => plugins.list().find(plugin => plugin.id === id) ?? null, + authHeaders: pluginRegistryAuthHeaders + }); + const pluginModelInference = createPluginModelInferenceService({ + resolveModel: (pluginId, requestedPreset) => resolvePluginModelSelection({ + accountOnly: pluginRepository.get(pluginId)?.manifest.modelPolicy?.requiredSource === "account", + userMode: options.appStateStore.repositories.bootstrap.getAppSettings().userMode, + account: accountSessionRepository.get(), + requestedPreset, + resolveAssignedModel: memmyConfigWriter.resolveAssignedModel + }), + embeddingInference: options.memoryClient.embeddingInference + ? (input, inferenceOptions) => options.memoryClient.embeddingInference!(input, inferenceOptions) + : undefined + }); + 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: pluginAudioRoots }) + } + ]); + const memmyAgentAdminClient = + options.memmyAgentAdminClient ?? + createHttpMemmyAgentAdminClient({ bootstrapSecret: options.memmyAgentAdminBootstrapSecret }); + const pluginRuntimeHost = options.pluginRuntimeHost ?? createPluginRuntimeHost(new PluginAdapterRegistry([ + createMcpPluginAdapter(), + createHttpPluginAdapter(), + createCommandPluginAdapter({ + allowedNetworkHosts: options.commandPluginNetworkAllowlist ?? resolveCommandPluginNetworkAllowlist(process.env), + fileInputRoots: pluginFileInputRoots, + pluginDataRoot: join(dirname(options.appStateStore.databasePath), "plugin-data"), + hostServices: pluginHostServices, + isEntitlementGranted + }) + ])); + const plugins = createPluginService({ + repository: pluginRepository, + hasActiveAgentTasks: async signal => { + if (!memmyAgentAdminClient.hasActiveTasks) throw new Error("Agent task activity is unavailable; update the runtime and retry"); + return memmyAgentAdminClient.hasActiveTasks(signal); + }, + secretStore: options.appStateStore.secretStore, + registry: options.pluginRegistry ?? unavailablePluginRegistry, + runtimeHost: pluginRuntimeHost, + artifactManager: options.pluginArtifactManager ?? createPluginArtifactManager({ + installRoot: join(dirname(options.appStateStore.databasePath), "plugins"), + trustedLocalRoots: options.trustedBundledPluginRoots, + authOrigin: resolvePluginRegistryBaseUrl(process.env), + authHeaders: pluginRegistryAuthHeaders + }), + onSkillContentInvalidated: (id) => pluginSkillContent.clear(id), + skillManager: createPluginSkillManager({ skillsRoot: join(resolveAgentWorkspace(process.env), "skills") }), + localArtifactService: createPluginLocalArtifactService({ + 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; + } + } + }), + isAuthenticated: () => accountSessionRepository.get().authenticated, + isEntitlementGranted, + catalogPluginIds: () => entitledPluginIds(accountSessionRepository.getEntitlements()) + }); + const pluginToolSignature = () => JSON.stringify(plugins.list() + .filter((plugin) => plugin.state === "active" && plugin.eligibility?.canUse !== false) + .map((plugin) => [plugin.id, plugin.version, plugin.manifest.capabilities]) + .sort((a, b) => String(a[0]).localeCompare(String(b[0])))); + let publishedPluginTools = pluginToolSignature(); const sourceRegistry = options.sourceRegistry ?? createBuiltinAgentSourceRegistry(); @@ -124,11 +281,6 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba createSkillDistributionService({ targetRegistry: skillTargetRegistry }); - const memmyAgentAdminClient = - options.memmyAgentAdminClient ?? - createHttpMemmyAgentAdminClient({ bootstrapSecret: options.memmyAgentAdminBootstrapSecret }); - const memmyConfigWriter = options.memmyConfigWriter ?? createUnavailableMemmyConfigWriter(); - const accountSessionRepository = options.appStateStore.repositories.accountSession; const resolveAnalyticsUserId = () => { const session = accountSessionRepository.get(); if (!session.authenticated) return null; @@ -196,7 +348,29 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba bootstrapRepository: options.appStateStore.repositories.bootstrap, memmyConfigWriter: options.memmyConfigWriter, memoryClient: options.memoryClient, - accountChannel: options.accountChannel + accountChannel: options.accountChannel, + onAccountGrantsRefreshed: async () => { + pluginSkillContent.clear(); + const failures = await reconcileEntitledPlugins({ + plugins, + userUninstalledIds: new Set(pluginRepository.listUserUninstalledIds()), + entitlements: accountSessionRepository.getEntitlements() + }); + for (const failure of failures) { + console.warn(`Entitled plugin reconciliation failed for ${failure.pluginId}: ${failure.message}`); + } + // Reconciliation bypasses management routes; retry publishing changes after a failed reload. + const nextPluginTools = pluginToolSignature(); + if (nextPluginTools !== publishedPluginTools) { + try { + const result = await memmyAgentAdminClient.reloadMcpConfig(); + if (result.ok) publishedPluginTools = nextPluginTools; + else console.warn(`Agent plugin tools refresh did not complete: ${result.message}`); + } catch (error) { + console.warn(`Agent plugin tool reload unavailable: ${error instanceof Error ? error.message : String(error)}`); + } + } + } }), integrations: createIntegrationService({ cloudClient: options.cloudClient, @@ -252,19 +426,40 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba bootstrapRepository: options.appStateStore.repositories.bootstrap, memoryClient: options.memoryClient }), - asr: createAsrService({ - bootstrapRepository: options.appStateStore.repositories.bootstrap, - accountSessionRepository: options.appStateStore.repositories.accountSession, - memmyConfigWriter, - cloudClient: options.cloudClient - }), + asr: asrService, + asrStream: asrStreamService, tokenQuota: createTokenQuotaService({ cloudClient: options.cloudClient, accountSessionRepository: options.appStateStore.repositories.accountSession - }) + }), + plugins, + pluginSkillContent }; } +export function resolveCommandPluginNetworkAllowlist(env: NodeJS.ProcessEnv): string[] { + const configured = env.MEMMY_COMMAND_PLUGIN_NETWORK_ALLOWLIST?.trim(); + if (!configured) return [...DEFAULT_COMMAND_PLUGIN_NETWORK_ALLOWLIST]; + return configured + .split(",") + .map((host) => host.trim().toLowerCase()) + .filter(Boolean); +} + +function resolveAgentWorkspace(env: NodeJS.ProcessEnv): string { + const configured = env.MEMMY_AGENT_WORKSPACE?.trim() || "~/.memmy/workspace"; + if (configured === "~") return homedir(); + if (configured.startsWith("~/")) return resolve(homedir(), configured.slice(2)); + return resolve(configured); +} + +function resolveAgentDataRoot(env: NodeJS.ProcessEnv): string { + const configured = env.MEMMY_AGENT_DATA_DIR?.trim() || "~/.memmy"; + if (configured === "~") return homedir(); + if (configured.startsWith("~/")) return resolve(homedir(), configured.slice(2)); + return resolve(configured); +} + export { createBootstrapService }; export type { BootstrapScenario, BootstrapService }; @@ -316,3 +511,9 @@ function createUnavailableMemmyConfigWriter(): MemmyConfigWriter { patchMcpServerConfig: async () => unavailable() }; } + +const unavailablePluginRegistry: PluginRegistry = { + async resolve() { + throw Object.assign(new Error("Plugin registry is not configured"), { code: "plugin_unavailable" }); + } +}; diff --git a/App/backend/src/services/plugin-asr-service.ts b/App/backend/src/services/plugin-asr-service.ts new file mode 100644 index 000000000..242585264 --- /dev/null +++ b/App/backend/src/services/plugin-asr-service.ts @@ -0,0 +1,125 @@ +/** Host-owned `asr` service letting plugins transcribe audio the user has uploaded. */ +import { readFile, realpath, stat } from "node:fs/promises"; +import { relative, resolve, sep } from "node:path"; +import { + ASR_MAX_AUDIO_BYTES, + AsrTranscriptionResponseSchema, + type AsrTranscriptionResponse +} from "@memmy/local-api-contracts"; +import type { PluginHostServiceInvoker } from "../adapters/outbound/plugin-runtime/index.js"; +import type { AsrService } from "./asr-service.js"; +import { z } from "zod"; + +const PluginAsrInputSchema = z.object({ + /** Path to an audio file inside a Host-owned upload root. */ + path: z.string().trim().min(1), + mimeType: z.string().trim().min(1).max(128).optional(), + durationMs: z.number().int().nonnegative().optional(), + /** Requests speaker separation. Honoured only by upstream models that support it. */ + diarization: z.boolean().optional() +}); + +const MIME_TYPE_BY_EXTENSION: Readonly> = { + ".aac": "audio/aac", + ".flac": "audio/flac", + ".m4a": "audio/mp4", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".opus": "audio/opus", + ".wav": "audio/wav", + ".webm": "audio/webm" +}; + +export interface CreatePluginAsrServiceOptions { + asr: AsrService; + /** Host-owned upload roots a plugin may read audio from. */ + audioRoots: readonly string[]; +} + +/** + * Transcribes an uploaded audio file on behalf of a plugin. + * + * The plugin passes a path rather than audio bytes: recordings run to hundreds + * of megabytes, which the line-delimited command runtime protocol cannot carry. + * Only paths inside a Host-owned upload root are accepted so a plugin cannot + * read arbitrary user audio through this service. + */ +export function createPluginAsrService(options: CreatePluginAsrServiceOptions): PluginHostServiceInvoker { + const audioRoots = options.audioRoots.map((root) => resolve(root)); + + return { + async invoke(call) { + if (call.service !== "asr") { + throw serviceError("host_service_unavailable", `Unknown Host service: ${call.service}`, false); + } + const input = PluginAsrInputSchema.parse(call.input); + const path = await resolveAudioPath(input.path, audioRoots); + const info = await stat(path); + if (!info.isFile()) throw serviceError("invalid_argument", "ASR input must be a regular file", false); + if (info.size === 0) throw serviceError("invalid_argument", "ASR input file is empty", false); + if (info.size > ASR_MAX_AUDIO_BYTES) { + // Rejected here so an oversized upload fails with a clear reason rather + // than as an opaque error from the upstream transcription API. + throw serviceError("invalid_argument", "ASR input file exceeded the size limit", false); + } + + const response: AsrTranscriptionResponse = await options.asr.transcribe({ + audioBase64: (await readFile(path)).toString("base64"), + mimeType: input.mimeType ?? guessMimeType(path), + durationMs: input.durationMs, + diarization: input.diarization + }); + return AsrTranscriptionResponseSchema.parse(response); + } + }; +} + +/** + * Resolves a plugin-supplied audio path inside the Host-owned upload roots. + * + * The path is canonicalised before the containment check so a symlink planted + * inside an upload root cannot point at an unrelated file. + * + * @param candidate the plugin-supplied path. + * @param audioRoots the resolved Host-owned upload roots. + * @returns the canonical path. + */ +async function resolveAudioPath(candidate: string, audioRoots: readonly string[]): Promise { + if (!audioRoots.length) { + throw serviceError("host_service_unavailable", "No Host-owned audio root is configured", false); + } + + let canonical: string; + try { + canonical = await realpath(resolve(candidate)); + } catch { + throw serviceError("invalid_argument", "ASR input file was not found", false); + } + + for (const root of audioRoots) { + const canonicalRoot = await realpath(root).catch(() => null); + if (!canonicalRoot) continue; + const offset = relative(canonicalRoot, canonical); + if (offset && !offset.startsWith(`..${sep}`) && offset !== ".." && !offset.startsWith(sep)) { + return canonical; + } + } + + throw serviceError("forbidden", "ASR input file is outside the Host-owned audio roots", false); +} + +/** + * Infers the media type from the file extension. + * + * @param path the canonical audio path. + * @returns the media type; falls back to a generic audio type. + */ +function guessMimeType(path: string): string { + const dot = path.lastIndexOf("."); + const extension = dot === -1 ? "" : path.slice(dot).toLowerCase(); + return MIME_TYPE_BY_EXTENSION[extension] ?? "audio/mpeg"; +} + +function serviceError(code: string, message: string, retryable: boolean): Error { + return Object.assign(new Error(message), { code, retryable }); +} 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/plugin-entitlement-reconcile-service.ts b/App/backend/src/services/plugin-entitlement-reconcile-service.ts new file mode 100644 index 000000000..f944092a0 --- /dev/null +++ b/App/backend/src/services/plugin-entitlement-reconcile-service.ts @@ -0,0 +1,112 @@ +/** Reconciles entitlement-gated plugins against the grants held by the signed-in account. */ +import type { PluginCatalogEntry } from "@memmy/local-api-contracts"; +import { pluginRules } from "./plugin-rules.js"; +import type { PluginService } from "./plugin-service.js"; + +/** Prefix of an account grant that names an installable plugin, e.g. `plugin:literature-review`. */ +export const PLUGIN_ENTITLEMENT_PREFIX = "plugin:"; + +export interface PluginEntitlementFailure { + pluginId: string; + message: string; +} + +export interface ReconcileEntitledPluginsOptions { + plugins: PluginService; + /** Grants held by the active account; empty when signed out. */ + entitlements: readonly string[]; + userUninstalledIds?: ReadonlySet; +} + +/** Derives the plugin ids an account is entitled to install. */ +export function entitledPluginIds(entitlements: readonly string[]): string[] { + return [...new Set( + entitlements + .filter((entitlement) => entitlement.startsWith(PLUGIN_ENTITLEMENT_PREFIX)) + .map((entitlement) => entitlement.slice(PLUGIN_ENTITLEMENT_PREFIX.length).trim()) + .filter((pluginId) => pluginId.length > 0) + )]; +} + +/** + * Installs and enables every plugin the account is entitled to, and disables + * the ones whose grant has been revoked. + * + * Revoked plugins are disabled rather than uninstalled so a mistaken or + * temporary revocation does not destroy locally generated plugin data. + * + * Failures are collected per plugin instead of thrown: a registry outage or one + * broken release must not prevent the rest of the app from starting. + */ +async function reconcileOnce( + options: ReconcileEntitledPluginsOptions +): Promise { + const failures: PluginEntitlementFailure[] = []; + const granted = new Set(options.entitlements); + + let catalog: PluginCatalogEntry[]; + try { catalog = await options.plugins.catalog?.() ?? []; } + catch (error) { + failures.push({ pluginId: "catalog", message: String(error) }); + catalog = []; + } + const declared = new Map(catalog.map((entry) => [entry.manifest.id, entry])); + const candidates = new Set([ + // Private plugins may deliberately be omitted from the public catalog. + // Their server-issued entitlement is the discovery signal; resolve still + // authenticates the account and returns an immutable release descriptor. + ...entitledPluginIds(options.entitlements), + ...catalog.filter((entry) => entry.eligibility.canInstall && pluginRules(entry.manifest).installation === "automatic").map((entry) => entry.manifest.id) + ]); + for (const pluginId of candidates) { + if (options.userUninstalledIds?.has(pluginId)) continue; + if (declared.has(pluginId) && !declared.get(pluginId)!.eligibility.canInstall) continue; + try { + const existing = options.plugins.list().find((plugin) => plugin.id === pluginId); + if (existing?.update) continue; + // Freeze the catalog selection across resolve/download, even if the server default changes. + const entry = declared.get(pluginId); + // Publishing a new catalog entry never authorizes replacing an installed + // plugin. Updates are an explicit user action, including legacy manifests + // with autoUpdate=true and entitlement-only/offline catalog paths. + let plugin = existing ?? (entry + ? await options.plugins.install(pluginId, entry.manifest.version) + : await options.plugins.install(pluginId)); + plugin = await options.plugins.approvePermissions(pluginId, plugin.manifest.permissions); + if (plugin.state !== "active") { + await options.plugins.enable(pluginId); + } + } catch (error) { + failures.push({ + pluginId, + message: error instanceof Error ? error.message : String(error) + }); + } + } + + for (const installed of options.plugins.list()) { + const required = installed.manifest.requiredEntitlement; + if (installed.eligibility ? installed.eligibility.canUse : (!required || granted.has(required))) continue; + if (installed.state !== "active" && installed.state !== "enabling") continue; + try { + if (options.plugins.suspend) await options.plugins.suspend(installed.id); + else await options.plugins.disable(installed.id); + } catch (error) { + failures.push({ + pluginId: installed.id, + message: error instanceof Error ? error.message : String(error) + }); + } + } + + return failures; +} + +// Login, panel refresh and startup may overlap; serialize installation decisions per Host. +const reconciliations = new WeakMap>(); +export function reconcileEntitledPlugins(options: ReconcileEntitledPluginsOptions): Promise { + const previous = reconciliations.get(options.plugins) ?? Promise.resolve([]); + const next = previous.catch(() => []).then(() => reconcileOnce(options)); + reconciliations.set(options.plugins, next); + return next; +} diff --git a/App/backend/src/services/plugin-host-service-router.ts b/App/backend/src/services/plugin-host-service-router.ts new file mode 100644 index 000000000..2f8af9a54 --- /dev/null +++ b/App/backend/src/services/plugin-host-service-router.ts @@ -0,0 +1,38 @@ +/** Routes plugin host-service calls to the invoker that owns each service name. */ +import type { PluginHostServiceInvoker } from "../adapters/outbound/plugin-runtime/index.js"; + +export interface PluginHostServiceRoute { + /** Service names this invoker answers, as declared in a plugin's `host-service` permission. */ + services: readonly string[]; + invoker: PluginHostServiceInvoker; +} + +/** + * Builds a single invoker from per-service routes. + * + * Routing is by explicit service name rather than by trying each invoker in + * turn, so an unknown service fails fast instead of being masked by whichever + * invoker happens to reject it last. + */ +export function createPluginHostServiceRouter(routes: readonly PluginHostServiceRoute[]): PluginHostServiceInvoker { + const byService = new Map(); + for (const route of routes) { + for (const service of route.services) { + if (byService.has(service)) throw new Error(`Duplicate Host service route: ${service}`); + byService.set(service, route.invoker); + } + } + + return { + async invoke(call) { + const invoker = byService.get(call.service); + if (!invoker) { + throw Object.assign(new Error(`Unknown Host service: ${call.service}`), { + code: "host_service_unavailable", + retryable: false + }); + } + return invoker.invoke(call); + } + }; +} diff --git a/App/backend/src/services/plugin-local-artifact-service.ts b/App/backend/src/services/plugin-local-artifact-service.ts new file mode 100644 index 000000000..2f497867a --- /dev/null +++ b/App/backend/src/services/plugin-local-artifact-service.ts @@ -0,0 +1,202 @@ +/** Validates plugin-produced local files and exposes opaque local API references. */ +import { randomUUID } from "node:crypto"; +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"; + +export interface HostedPluginArtifact { + path: string; + name: string; + mediaType: string; +} + +export interface PluginLocalArtifactService { + host( + plugin: { id: string; approvedPermissions: PluginPermission[]; config: Record }, + artifact: PluginArtifactRef, + context?: PluginArtifactHostContext + ): Promise; + open(pluginId: string, token: string): Promise; + 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; +} + +export function createPluginLocalArtifactService(options: CreatePluginLocalArtifactServiceOptions = {}): PluginLocalArtifactService { + const artifacts = new Map(); + return { + async host(plugin, artifact, context) { + let url: URL; + try { + url = new URL(artifact.uri); + } catch { + throw pluginArtifactError("Plugin artifact URI must be an absolute HTTP(S) or file URI"); + } + if (url.protocol === "https:" || url.protocol === "http:") return artifact; + if (url.protocol !== "file:") throw pluginArtifactError(`Unsupported plugin artifact URI protocol: ${url.protocol}`); + const canHostArtifacts = plugin.approvedPermissions.some((permission) => ( + permission.type === "host-service" && permission.services.includes("artifact-host") + )); + if (!canHostArtifacts) throw Object.assign(new Error("Plugin has not been approved to use the artifact Host service"), { + code: "plugin_permission_denied" + }); + + const requestedPath = fileURLToPath(url); + if (!isAbsolute(requestedPath)) throw pluginArtifactError("Plugin local artifact path must be absolute"); + const path = await realpath(requestedPath); + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) throw pluginArtifactError("Plugin local artifact must be a regular file"); + const allowed = await approvedFilesystemRoots(plugin, options.pluginDataRoot); + if (!allowed.some((root) => isWithin(root, path))) { + throw Object.assign(new Error("Plugin local artifact is outside its approved filesystem paths"), { + code: "plugin_permission_denied" + }); + } + + 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)}`; + const publishedPath = context + ? await publishArtifactToWorkspace(path, plugin.id, artifact.name, artifact.role, context, options.resolveWorkspace) + : null; + return { + ...artifact, + uri: `${base}/preview`, + downloadUri: `${base}/download`, + ...(publishedPath ? { path: publishedPath } : {}) + }; + }, + + async open(pluginId, token) { + const artifact = artifacts.get(token); + if (!artifact || artifact.pluginId !== pluginId) throw Object.assign(new Error("Plugin artifact not found"), { code: "not_found" }); + const path = await realpath(artifact.path); + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) throw Object.assign(new Error("Plugin artifact is no longer available"), { code: "not_found" }); + return { path, name: artifact.name, mediaType: artifact.mediaType }; + }, + + revokePlugin(pluginId) { + for (const [token, artifact] of artifacts) if (artifact.pluginId === pluginId) artifacts.delete(token); + } + }; +} + +async function publishArtifactToWorkspace( + sourcePath: string, + pluginId: string, + artifactName: string, + artifactRole: PluginArtifactRef["role"], + context: PluginArtifactHostContext, + resolveWorkspace: CreatePluginLocalArtifactServiceOptions["resolveWorkspace"] +): Promise { + if (!resolveWorkspace) return null; + // A setup artifact is an input the user downloads and fills in — a blank + // template, a checklist. `outputs/` holds what an engagement produced, so + // publishing templates here would bury the report among blank forms. + if (artifactRole === "setup") return null; + const workspacePath = await resolveWorkspace(context.conversationId); + if (!workspacePath) return null; + const workspace = await realpath(workspacePath); + const relativeName = safeArtifactRelativePath(artifactName); + // One directory per task, or per conversation when the plugin has no task + // concept. Keying on the conversation keeps the files of one engagement + // together and lets a re-run overwrite its own earlier output instead of + // leaving a duplicate beside it. + const taskDirectory = safePathSegment(context.taskId ?? context.conversationId, "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 { + 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 +): Promise { + const paths = plugin.approvedPermissions.flatMap((permission) => permission.type === "filesystem" ? permission.paths : []); + const hasPluginData = plugin.approvedPermissions.some((permission) => permission.type === "host-service" && permission.services.includes("plugin-data")); + if (hasPluginData && pluginDataRoot) paths.push(resolve(pluginDataRoot, plugin.id)); + return Promise.all(paths.map((path) => realpath(path))); +} + +function isWithin(parent: string, child: string): boolean { + const path = relative(resolve(parent), resolve(child)); + return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path)); +} + +function pluginArtifactError(message: string): Error { + return Object.assign(new Error(message), { code: "plugin_invalid" }); +} diff --git a/App/backend/src/services/plugin-model-inference-service.ts b/App/backend/src/services/plugin-model-inference-service.ts new file mode 100644 index 000000000..f30f4464b --- /dev/null +++ b/App/backend/src/services/plugin-model-inference-service.ts @@ -0,0 +1,571 @@ +import { z } from "zod"; +import { + EmbeddingInferenceInputSchema, + type EmbeddingInferenceInput, + type EmbeddingInferenceOutput, + type ModelSelectionResolution +} from "@memmy/local-api-contracts"; +import type { PluginHostServiceCall, PluginHostServiceInvoker } from "../adapters/outbound/plugin-runtime/index.js"; + +const MAX_INPUT_CHARACTERS = 200_000; +/** + * Ceiling a plugin may ask for, sized for a reasoning model's own consumption. + * + * The answer is not all a caller pays for: asking the account gateway for a + * list of two dozen records measured 5.8k reasoning tokens on top of 4.3k of + * answer, so 8192 cut the answer off mid-object while nothing about the request + * was unreasonable. A budget is a ceiling, not a reservation — an unused one + * costs nothing. + */ +const MAX_OUTPUT_TOKENS = 32_768; +/** + * Matches the budget an ordinary chat turn gets, for the same reason. + * + * A reasoning model spends this budget on its own reasoning before it writes + * anything, so 2048 was not a smaller answer but an empty one: the reasoning + * alone reached the ceiling and the response carried no content. + */ +const DEFAULT_OUTPUT_TOKENS = 4_096; +const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_TIMEOUT_MS = 300_000; +const DEFAULT_MAX_ATTEMPTS = 2; + +const ModelInferenceInputSchema = z.object({ + messages: z.array(z.object({ + role: z.enum(["system", "user", "assistant"]), + content: z.string().min(1).max(MAX_INPUT_CHARACTERS) + })).min(1).max(100), + temperature: z.number().min(0).max(2).optional(), + maxOutputTokens: z.number().int().positive().max(MAX_OUTPUT_TOKENS).optional(), + responseFormat: z.enum(["text", "json"]).default("text"), + timeoutMs: z.number().int().min(1_000).max(MAX_TIMEOUT_MS).optional(), + thinkingMode: z.literal("disabled").optional(), + thinkingBudgetTokens: z.number().int().min(1).max(8_192).optional(), + maxAttempts: z.number().int().min(1).max(DEFAULT_MAX_ATTEMPTS).optional() +}).superRefine((input, context) => { + const total = input.messages.reduce((sum, message) => sum + message.content.length, 0); + if (total > MAX_INPUT_CHARACTERS) context.addIssue({ code: "custom", path: ["messages"], message: `Total message content exceeds ${MAX_INPUT_CHARACTERS} characters` }); +}); + +export type PluginModelInferenceInput = z.input; +export interface PluginModelInferenceResult { + content: string; + finishReason: string; + usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number; reasoningTokens?: number }; + model: { provider: string; model: string }; +} + +export interface CreatePluginModelInferenceServiceOptions { + /** Resolves the model preset the calling plugin is allowed to reach, honouring its manifest model policy. */ + resolveModel: (pluginId: string, requestedPreset?: string) => Promise; + embeddingInference?: (input: EmbeddingInferenceInput, options?: { signal?: AbortSignal }) => Promise; + fetch?: typeof fetch; + timeoutMs?: number; + maxAttempts?: number; + retryBaseDelayMs?: number; +} + +export function createPluginModelInferenceService(options: CreatePluginModelInferenceServiceOptions): PluginHostServiceInvoker { + const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); + return { + async invoke(call) { + throwIfCallerAborted(call.signal); + if (call.service === "embedding-inference") { + if (!options.embeddingInference) { + throw serviceError("embedding_unavailable", "The current embedding model is not available to plugins", false); + } + const input = EmbeddingInferenceInputSchema.parse(call.input); + try { + return await raceWithAbort(options.embeddingInference(input, { signal: call.signal }), call.signal); + } catch (error) { + if (hasServiceErrorCode(error)) throw error; + throw serviceError( + "embedding_inference_failed", + error instanceof Error ? error.message : "Embedding inference failed", + true + ); + } + } + if (call.service !== "model-inference") throw serviceError("host_service_unavailable", `Unknown Host service: ${call.service}`, false); + const input = ModelInferenceInputSchema.parse(call.input); + const resolved = await options.resolveModel(call.pluginId, call.modelPreset); + if (!resolved?.ok) throw serviceError("model_unavailable", "The current user model is not configured or available", false); + const configuredAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS); + const maxAttempts = input.maxAttempts === undefined ? configuredAttempts : Math.min(configuredAttempts, input.maxAttempts); + let lastError: unknown; + let attemptInput = input; + let remainingAttempts = maxAttempts; + let usedJsonCompatibilityFallback = false; + while (remainingAttempts > 0) { + remainingAttempts -= 1; + try { + throwIfCallerAborted(call.signal); + const timeoutMs = Math.min( + deadlineTimeout(call, options.timeoutMs ?? attemptInput.timeoutMs ?? DEFAULT_TIMEOUT_MS), + attemptInput.timeoutMs ?? DEFAULT_TIMEOUT_MS + ); + return await infer(resolved, attemptInput, input.responseFormat, fetchImpl, timeoutMs, call.signal); + } catch (error) { + lastError = error; + // Some OpenAI-compatible gateways occasionally return an empty choice + // when response_format=json_object is requested. The prompt still asks + // for JSON, so retrying without the transport-level JSON constraint is + // a safe, single compatibility fallback. It is transport negotiation, + // so it remains available even when the plugin disables ordinary retries. + const canUseJsonFallback = !usedJsonCompatibilityFallback + && serviceErrorCode(error) === "model_empty_response" + && attemptInput.responseFormat === "json"; + if (canUseJsonFallback) { + usedJsonCompatibilityFallback = true; + attemptInput = { ...attemptInput, responseFormat: "text" }; + remainingAttempts += 1; + } else if (remainingAttempts <= 0 || !isRetryableServiceError(error)) { + throw error; + } + const baseDelayMs = Math.max(0, Math.min(30_000, options.retryBaseDelayMs ?? 250)); + const attemptsUsed = maxAttempts - remainingAttempts; + const exponentialDelayMs = baseDelayMs * (2 ** Math.max(0, attemptsUsed - 1)); + const jitterMs = baseDelayMs > 0 ? Math.floor(Math.random() * Math.max(1, baseDelayMs * 0.2)) : 0; + await abortableDelay(exponentialDelayMs + jitterMs, call.signal); + } + } + throw lastError; + } + }; +} + +async function infer( + resolved: Extract, + input: z.output, + /** What the plugin asked for, which the JSON compatibility fallback rewrites below. */ + requestedFormat: z.output["responseFormat"], + fetchImpl: typeof fetch, + timeoutMs: number, + callerSignal?: AbortSignal +): Promise { + const protocol = resolved.context.protocol; + const maxTokens = input.maxOutputTokens ?? DEFAULT_OUTPUT_TOKENS; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const abortFromCaller = () => controller.abort(callerSignal?.reason); + callerSignal?.addEventListener("abort", abortFromCaller, { once: true }); + try { + throwIfCallerAborted(callerSignal); + const request = requestForProtocol(resolved, input, maxTokens, controller.signal); + const response = await fetchImpl(request.url, request.init); + if (!response.ok) { + const retryable = response.status === 408 || response.status === 429 || response.status >= 500; + throw serviceError("model_inference_failed", `Current user model returned HTTP ${response.status}`, retryable); + } + // Not every 200 carries JSON: the account gateway answers a bare + // `stream timeout` when its own upstream stalls, which parses to a + // SyntaxError that says nothing about the model and retries nothing. It is + // a transient upstream failure, so it is named and retried as one. + const raw = await response.text(); + let body: unknown; + try { + body = JSON.parse(raw); + } catch { + throw serviceError( + "model_inference_failed", + `Current user model returned a non-JSON body: ${raw.trim().slice(0, 200) || "(empty)"}`, + true + ); + } + if (protocol === "memmy-account") { + const envelope = record(body); + const error = record(envelope.error); + const code = String(envelope.code ?? error.code ?? ""); + if (code === "40308" || code === "40309" || error.type === "insufficient_quota") { + throw serviceError("model_quota_exhausted", "Account Agent token quota is insufficient; replenish quota before resuming the saved task", false); + } + } + const parsed = extractResponse(protocol, body); + // An exhausted budget surfaces two ways, and a reasoning model reaches it + // before writing anything: no content at all, or content cut mid-object. + // Both are the same problem and no retry can fix either, so they are named + // as the ceiling. Reported against what the plugin asked for rather than + // the current attempt, because the fallback below rewrites the format and + // would otherwise hand a half-written body back as if it were the answer. + // Text callers keep a partial answer plus the finish reason to judge it by. + if (isTruncated(parsed.finishReason) && (!parsed.content.trim() || requestedFormat === "json")) { + throw serviceError( + "model_response_truncated", + `Model output was truncated after reaching this request's ${maxTokens}-token output budget (maxOutputTokens), not the user's global model setting; finishReason=${parsed.finishReason}; contentCharacters=${parsed.content.length}; outputTokens=${parsed.usage.outputTokens ?? "unknown"}; reasoningTokens=${parsed.usage.reasoningTokens ?? "unknown"}; thinkingStrategy=${input.thinkingMode === "disabled" ? thinkingStrategyFor(resolved) : "inherit"}`, + false + ); + } + if (!parsed.content.trim()) throw serviceError("model_empty_response", "Current user model returned no content", true); + return { ...parsed, model: { provider: resolved.context.provider, model: resolved.context.model } }; + } catch (error) { + if (callerSignal?.aborted) throw cancelledError(); + if (controller.signal.aborted) throw serviceError("model_inference_timeout", "Current user model request timed out", true); + throw error; + } finally { + clearTimeout(timer); + callerSignal?.removeEventListener("abort", abortFromCaller); + } +} + +function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { + if (milliseconds <= 0) return Promise.resolve(); + throwIfCallerAborted(signal); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + reject(cancelledError()); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function raceWithAbort(operation: Promise, signal?: AbortSignal): Promise { + throwIfCallerAborted(signal); + if (!signal) return operation; + return new Promise((resolve, reject) => { + const onAbort = () => reject(cancelledError()); + signal.addEventListener("abort", onAbort, { once: true }); + operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); + }); +} + +function throwIfCallerAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw cancelledError(); +} + +function cancelledError(): Error { + return serviceError("plugin_call_cancelled", "The owning plugin call was cancelled", false); +} + +function requestForProtocol( + resolved: Extract, + input: z.output, + maxTokens: number, + signal: AbortSignal +): { url: string; init: RequestInit } { + const { context, provider } = resolved; + const headers = { "content-type": "application/json", ...(provider.extraHeaders ?? {}) }; + const common = { method: "POST", signal }; + const disableThinking = input.thinkingMode === "disabled"; + const thinkingStrategy = disableThinking ? thinkingStrategyFor(resolved) : "inherit"; + if (context.protocol === "anthropic-messages") { + const system = input.messages.filter((message) => message.role === "system").map((message) => message.content).join("\n\n"); + const extraBody = thinkingStrategy === "anthropic-disabled" + ? withoutThinkingDefaults(provider.extraBody) + : { ...(provider.extraBody ?? {}) }; + return { + url: endpoint(provider.apiBase, "/v1/messages"), + init: { ...common, headers: { ...headers, "x-api-key": provider.apiKey ?? "", "anthropic-version": "2023-06-01" }, body: JSON.stringify({ + model: context.model, ...(system ? { system } : {}), messages: input.messages.filter((message) => message.role !== "system"), + max_tokens: maxTokens, temperature: input.temperature ?? 0.2, ...extraBody, + ...(thinkingStrategy === "anthropic-disabled" ? { thinking: { type: "disabled" } } : {}) + }) } + }; + } + if (context.protocol === "gemini-generate-content") { + const url = new URL(endpoint(provider.apiBase, `/v1beta/models/${encodeURIComponent(context.model)}:generateContent`)); + if (provider.apiKey) url.searchParams.set("key", provider.apiKey); + const system = input.messages.filter((message) => message.role === "system").map((message) => message.content).join("\n\n"); + const extraBody = { ...(provider.extraBody ?? {}) }; + const configuredGeneration = thinkingStrategy === "gemini-budget-zero" ? { ...record(extraBody.generationConfig) } : {}; + if (thinkingStrategy === "gemini-budget-zero") { + delete configuredGeneration.thinkingConfig; + delete extraBody.generationConfig; + } + return { + url: url.href, + init: { ...common, headers, body: JSON.stringify({ + ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}), + contents: input.messages.filter((message) => message.role !== "system").map((message) => ({ role: message.role === "assistant" ? "model" : "user", parts: [{ text: message.content }] })), + generationConfig: { + ...configuredGeneration, + maxOutputTokens: maxTokens, + temperature: input.temperature ?? 0.2, + ...(input.responseFormat === "json" ? { responseMimeType: "application/json" } : {}), + ...(thinkingStrategy === "gemini-budget-zero" ? { thinkingConfig: { thinkingBudget: 0 } } : {}) + }, + ...extraBody + }) } + }; + } + if (context.protocol === "openai-responses") { + const extraBody = thinkingStrategy === "reasoning-none" + ? withoutThinkingDefaults(provider.extraBody) + : { ...(provider.extraBody ?? {}) }; + return { + url: endpoint(provider.apiBase, "/v1/responses"), + init: { ...common, headers: { ...headers, authorization: `Bearer ${provider.apiKey ?? ""}` }, body: JSON.stringify({ + model: context.model, input: input.messages, max_output_tokens: maxTokens, + ...(input.temperature !== undefined && !isOpenAiReasoningModel(modelSlug(context.model)) ? { temperature: input.temperature } : {}), + ...extraBody, + ...(thinkingStrategy === "reasoning-none" ? { reasoning: { effort: "none" } } : {}) + }) } + }; + } + // Translate the plugin-level intent into the selected endpoint's wire format. + // Unknown and always-on models receive no vendor-specific field. + const extraBody = disableThinking && thinkingStrategy !== "omit" + ? withoutThinkingDefaults(provider.extraBody) + : { ...(provider.extraBody ?? {}) }; + const supportsQwenBudget = /^qwen3\.(?:[5-9]|[1-9]\d+)-(?:flash|plus|max)(?:$|-)/iu.test(context.model) + && !/^qwen3\.[56]-max(?:$|-)/iu.test(context.model); + const boundedQwen = !disableThinking && input.thinkingBudgetTokens !== undefined && supportsQwenBudget; + let tokenLimits: Record = { max_tokens: maxTokens }; + if (disableThinking) { + if (thinkingStrategy === "enable-thinking-false" && extraBody.chat_template_kwargs && typeof extraBody.chat_template_kwargs === "object") { + extraBody.chat_template_kwargs = { ...record(extraBody.chat_template_kwargs), enable_thinking: false }; + } + tokenLimits = { + ...thinkingFields(thinkingStrategy), + ...(usesMaxCompletionTokens(thinkingStrategy, context.model) + ? { max_completion_tokens: maxTokens } + : { max_tokens: maxTokens }) + }; + } else if (boundedQwen) { + delete extraBody.reasoning_effort; + delete extraBody.thinking_budget; + delete extraBody.max_tokens; + delete extraBody.max_completion_tokens; + tokenLimits = { + thinking_budget: input.thinkingBudgetTokens, + max_completion_tokens: maxTokens + input.thinkingBudgetTokens! + }; + } + return { + url: endpoint(provider.apiBase, "/v1/chat/completions"), + init: { ...common, headers: { ...headers, authorization: `Bearer ${provider.apiKey ?? ""}` }, body: JSON.stringify({ + model: context.model, messages: input.messages, ...tokenLimits, + ...(!isOpenAiReasoningModel(modelSlug(context.model)) ? { temperature: input.temperature ?? 0.2 } : {}), + ...(input.responseFormat === "json" ? { response_format: { type: "json_object" } } : {}), ...extraBody + }) } + }; +} + +type ThinkingStrategy = + | "inherit" + | "omit" + | "anthropic-disabled" + | "gemini-budget-zero" + | "reasoning-effort-none" + | "reasoning-none" + | "thinking-type-disabled" + | "thinking-adaptive-disabled" + | "enable-thinking-false"; + +function thinkingStrategyFor( + resolved: Extract +): ThinkingStrategy { + const { context, provider } = resolved; + const slug = modelSlug(context.model); + if (context.protocol === "anthropic-messages") { + return isAnthropicThinkingCapableModel(slug) && !isAlwaysOnThinkingModel(slug) + ? "anthropic-disabled" + : "omit"; + } + if (context.protocol === "gemini-generate-content") { + return /^gemini-2\.5(?!-pro)(?:[.-]|$)/u.test(slug) ? "gemini-budget-zero" : "omit"; + } + if (context.protocol === "openai-responses") { + return isOpenAiReasoningModel(slug) && supportsOpenAiReasoningNone(slug) + ? "reasoning-none" + : "omit"; + } + // Match ordinary account chat's explicit non-thinking contract for the legacy + // alias (providers/memmy-account.ts). Vendor detection cannot identify this + // opaque name; silently omitting the switch lets reasoning consume a plugin's + // answer-sized budget before any prose is returned. + if (context.protocol === "memmy-account" && slug === "agent_chat") return "enable-thinking-false"; + if (context.protocol !== "openai-chat-completions" && context.protocol !== "memmy-account") return "omit"; + return openAiChatThinkingStrategy(context.provider, provider.apiBase, slug); +} + +function openAiChatThinkingStrategy(vendorValue: string, endpointValue: string, slug: string): ThinkingStrategy { + if (isAlwaysOnThinkingModel(slug)) return "omit"; + const vendor = vendorValue.trim().toLowerCase(); + const endpoint = endpointValue.trim().toLowerCase(); + const haystack = `${vendor} ${endpoint} ${slug}`; + if (haystack.includes("openrouter")) return "reasoning-none"; + if (isAlibabaCompatibleEndpoint(endpoint)) { + return vendor === "minimax" || slug.includes("minimax") + ? "thinking-adaptive-disabled" + : "enable-thinking-false"; + } + if (vendor === "qwen" || vendor === "dashscope") return "enable-thinking-false"; + if (vendor === "minimax") { + return /^minimax-m3(?:[.-]|$)/u.test(slug) ? "thinking-adaptive-disabled" : "omit"; + } + if ((vendor === "baidu" || vendor === "qianfan") && (slug.includes("ernie") || slug.includes("qwen"))) { + return "enable-thinking-false"; + } + if (["deepseek", "zhipu", "kimi", "moonshot", "baidu", "qianfan", "doubao", "volcengine"].includes(vendor)) { + return "thinking-type-disabled"; + } + if ( + haystack.includes("dashscope") || + haystack.includes("qwen") + ) { + return "enable-thinking-false"; + } + if (haystack.includes("minimax")) return /^minimax-m3(?:[.-]|$)/u.test(slug) ? "thinking-adaptive-disabled" : "omit"; + if ( + haystack.includes("volces") || + haystack.includes("volcengine") || + haystack.includes("byteplus") || + haystack.includes("deepseek") || + haystack.includes("bigmodel") || + haystack.includes("zhipu") || + haystack.includes("moonshot") || + haystack.includes("qianfan") || + haystack.includes("xiaomimimo") || + slug.includes("glm-") || + slug.includes("kimi-k2.5") || + slug.includes("kimi-k2.6") || + slug.includes("kimi-k2.7") || + slug.includes("k2.6-code-preview") || + slug.includes("mimo-v2") + ) { + return "thinking-type-disabled"; + } + if (isOpenAiReasoningModel(slug) && supportsOpenAiReasoningNone(slug)) return "reasoning-effort-none"; + return "omit"; +} + +function thinkingFields(strategy: ThinkingStrategy): Record { + switch (strategy) { + case "reasoning-effort-none": return { reasoning_effort: "none" }; + case "reasoning-none": return { reasoning: { effort: "none" } }; + case "thinking-type-disabled": return { thinking: { type: "disabled" } }; + case "thinking-adaptive-disabled": return { thinking: { type: "disabled" } }; + case "enable-thinking-false": return { enable_thinking: false }; + default: return {}; + } +} + +function withoutThinkingDefaults(value: Readonly> | undefined): Record { + const result = { ...(value ?? {}) }; + for (const key of [ + "reasoning_effort", "reasoning", "thinking_budget", "thinking", + "enable_thinking", "reasoning_split", "output_config", + "max_tokens", "max_completion_tokens", "max_output_tokens" + ]) delete result[key]; + if (result.chat_template_kwargs && typeof result.chat_template_kwargs === "object") { + const template = { ...record(result.chat_template_kwargs) }; + delete template.enable_thinking; + result.chat_template_kwargs = template; + } + return result; +} + +function usesMaxCompletionTokens(strategy: ThinkingStrategy, model: string): boolean { + return strategy === "reasoning-effort-none" + || (strategy === "enable-thinking-false" + && /^qwen3\.(?:[5-9]|[1-9]\d+)-(?:flash|plus|max)(?:$|-)/iu.test(model) + && !/^qwen3\.[56]-max(?:$|-)/iu.test(model)); +} + +function isAlibabaCompatibleEndpoint(endpoint: string): boolean { + return endpoint.includes("dashscope") || endpoint.includes("aliyuncs.com") || endpoint.includes("alibabacloud.com"); +} + +function isOpenAiReasoningModel(slug: string): boolean { + return /^(o[134]\b|o[134][.-]|gpt-[5-9]\b|gpt-[5-9][.-])/u.test(slug); +} + +function supportsOpenAiReasoningNone(slug: string): boolean { + const version = slug.match(/^gpt-(\d+)(?:\.(\d+))?/u); + if (!version) return false; + const major = Number(version[1]); + const minor = Number(version[2] ?? 0); + return major > 5 || (major === 5 && minor >= 1); +} + +function isAnthropicThinkingCapableModel(slug: string): boolean { + return slug.includes("claude-3-7") + || /claude-(?:opus|sonnet|haiku)?-?4(?:[.-]|$)/u.test(slug) + || /claude-(?:opus|sonnet|haiku)?-?5(?:[.-]|$)/u.test(slug) + || slug.includes("fable") + || slug.includes("mythos"); +} + +function isAlwaysOnThinkingModel(slug: string): boolean { + return slug.includes("deepseek-r1") + || slug.includes("deepseek-reasoner") + || slug.startsWith("kimi-k2.7-code") + || slug.startsWith("qwq") + || (slug.includes("-thinking") && !slug.startsWith("ernie-")) + || /^qwen3\.[56]-max(?:$|-)/u.test(slug) + || slug === "qwen3.7-max-preview" + || slug.includes("qwen3.7-max-2026-05-17") + || /^minimax-m2(?:[.-]|$)/u.test(slug) + || slug.includes("fable") + || slug.includes("mythos"); +} + +function modelSlug(model: string): string { + return model.trim().toLowerCase().split("/").at(-1) ?? model.trim().toLowerCase(); +} + +function extractResponse(protocol: string, value: unknown): Omit { + const body = record(value); + if (protocol === "anthropic-messages") { + const content = Array.isArray(body.content) ? body.content.map(record).filter((item) => item.type === "text").map((item) => string(item.text)).join("") : ""; + return { content, finishReason: string(body.stop_reason) || "stop", usage: usage(body.usage, "input_tokens", "output_tokens") }; + } + if (protocol === "gemini-generate-content") { + const candidates = Array.isArray(body.candidates) ? body.candidates.map(record) : []; + const parts = Array.isArray(record(candidates[0]?.content).parts) ? (record(candidates[0]?.content).parts as unknown[]).map(record) : []; + return { content: parts.map((part) => string(part.text)).join(""), finishReason: string(candidates[0]?.finishReason) || "stop", usage: usage(body.usageMetadata, "promptTokenCount", "candidatesTokenCount", "totalTokenCount") }; + } + if (protocol === "openai-responses") { + const output = Array.isArray(body.output) ? body.output.map(record) : []; + const content = output.flatMap((item) => Array.isArray(item.content) ? (item.content as unknown[]).map(record) : []).filter((item) => item.type === "output_text").map((item) => string(item.text)).join(""); + return { content: string(body.output_text) || content, finishReason: string(body.status) || "completed", usage: usage(body.usage, "input_tokens", "output_tokens", "total_tokens") }; + } + const choice = Array.isArray(body.choices) ? record(body.choices[0]) : {}; + const reasoningTokens = numeric(record(record(body.usage).completion_tokens_details).reasoning_tokens); + return { content: string(record(choice.message).content), finishReason: string(choice.finish_reason) || "stop", usage: { + ...usage(body.usage, "prompt_tokens", "completion_tokens", "total_tokens"), + ...(reasoningTokens !== undefined ? { reasoningTokens } : {}) + } }; +} + +/** Recognises each protocol's way of saying the output hit the token ceiling. */ +function isTruncated(finishReason: string): boolean { + const reason = finishReason.trim().toLowerCase(); + return reason === "length" || reason === "max_tokens" || reason === "incomplete"; +} + +function endpoint(base: string, suffix: string): string { + const normalized = base.replace(/\/+$/, ""); + if (normalized.endsWith(suffix)) return normalized; + const versioned = suffix.match(/^\/(v\d+(?:beta\d*)?)(\/.*)$/u); + if (versioned && normalized.endsWith(`/${versioned[1]}`)) return `${normalized}${versioned[2]}`; + return `${normalized}${suffix}`; +} + +function usage(value: unknown, inputKey: string, outputKey: string, totalKey?: string): PluginModelInferenceResult["usage"] { + const source = record(value); const inputTokens = numeric(source[inputKey]); const outputTokens = numeric(source[outputKey]); const totalTokens = numeric(totalKey ? source[totalKey] : undefined) ?? (inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined); + return { ...(inputTokens !== undefined ? { inputTokens } : {}), ...(outputTokens !== undefined ? { outputTokens } : {}), ...(totalTokens !== undefined ? { totalTokens } : {}) }; +} + +function deadlineTimeout(call: PluginHostServiceCall, fallback: number): number { + if (!call.deadline) return fallback; + return Math.max(1, Math.min(fallback, Date.parse(call.deadline) - Date.now())); +} +function serviceError(code: string, message: string, retryable: boolean): Error { return Object.assign(new Error(message), { code, retryable }); } +function hasServiceErrorCode(error: unknown): error is Error & { code: string; retryable?: boolean } { + return error instanceof Error && typeof (error as Error & { code?: unknown }).code === "string"; +} +function isRetryableServiceError(error: unknown): error is Error & { retryable: true } { + return error instanceof Error && (error as Error & { retryable?: unknown }).retryable === true; +} +function serviceErrorCode(error: unknown): string | undefined { + return error instanceof Error ? (error as Error & { code?: string }).code : undefined; +} +function record(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } +function string(value: unknown): string { return typeof value === "string" ? value : ""; } +function numeric(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } diff --git a/App/backend/src/services/plugin-model-policy.ts b/App/backend/src/services/plugin-model-policy.ts new file mode 100644 index 000000000..495528849 --- /dev/null +++ b/App/backend/src/services/plugin-model-policy.ts @@ -0,0 +1,50 @@ +/** Decides which model preset a plugin's inference calls may use. */ +import type { AccountSessionView, ModelSelectionResolution, UserMode } from "@memmy/local-api-contracts"; +import type { MemmyConfigWriter } from "../infrastructure/memmy-config/index.js"; + +export interface ResolvePluginModelSelectionInput { + /** True when the plugin manifest declares `modelPolicy.requiredSource: "account"`. */ + accountOnly: boolean; + userMode: UserMode | string; + account: AccountSessionView; + /** Exact preset selected for the conversation turn, if the call came from Agent. */ + requestedPreset?: string; + resolveAssignedModel: MemmyConfigWriter["resolveAssignedModel"]; +} + +/** + * Resolves the model preset a plugin may call, or null to deny the call. + * + * An account-only plugin is one the Host distributes and whose prompts are + * sealed, so its traffic has to reach our gateway to be unsealed at all. Being + * in account mode is not sufficient for that: account mode still permits BYOK + * presets, which would send the request to a third-party endpoint. So the + * resolved preset's own source is checked, not just the mode. + * + * @param input the plugin's policy, the current mode and account, and the preset resolver. + * @returns the permitted preset, or null when the plugin may not call a model. + */ +export async function resolvePluginModelSelection( + input: ResolvePluginModelSelectionInput +): Promise { + const activeAccountId = input.account.authenticated ? input.account.profile.userId : null; + + if (input.accountOnly) { + if (input.userMode !== "account" || !activeAccountId) return null; + const preset = await input.resolveAssignedModel?.({ + mode: "account", + activeAccountId, + capability: "agent", + ...(input.requestedPreset ? { requestedPreset: input.requestedPreset } : {}) + }); + return preset?.ok && preset.context.source === "account" ? preset : null; + } + + if (input.userMode !== "account" && input.userMode !== "byok") return null; + return await input.resolveAssignedModel?.({ + mode: input.userMode, + activeAccountId, + capability: "agent", + ...(input.requestedPreset ? { requestedPreset: input.requestedPreset } : {}) + }) ?? null; +} diff --git a/App/backend/src/services/plugin-rules.ts b/App/backend/src/services/plugin-rules.ts new file mode 100644 index 000000000..42860b933 --- /dev/null +++ b/App/backend/src/services/plugin-rules.ts @@ -0,0 +1,19 @@ +/** Evaluates plugin policy against current server-issued account facts. */ +import { PluginRulesSchema, type PluginManifest, type PluginEligibility } from "@memmy/local-api-contracts"; + +export function pluginRules(manifest: Pick) { + // Legacy private manifests predate rules. Do not expose them after a grant + // is revoked; an explicit policy can still opt into an unavailable listing. + return PluginRulesSchema.parse(manifest.rules ?? { + unavailableVisibility: manifest.requiredEntitlement ? "hidden" : "visible" + }); +} + +export function pluginEligibility(manifest: Pick, + authenticated: boolean, hasEntitlement: (grant: string) => boolean): PluginEligibility { + const rules = pluginRules(manifest); + const entitlement = manifest.requiredEntitlement; + const reason = (rules.requiresLogin || rules.distribution === "account") && !authenticated + ? "login_required" : entitlement && !hasEntitlement(entitlement) ? "entitlement_required" : null; + return { visible: !reason || rules.unavailableVisibility === "visible", canInstall: !reason, canUse: !reason, reason }; +} diff --git a/App/backend/src/services/plugin-service.ts b/App/backend/src/services/plugin-service.ts new file mode 100644 index 000000000..f9b17cdda --- /dev/null +++ b/App/backend/src/services/plugin-service.ts @@ -0,0 +1,598 @@ +import { setTimeout as delay } from "node:timers/promises"; +/** Plugin installation and lifecycle service. */ +import { + CapabilityCallSchema, + PluginManifestSchema, + PluginPermissionSchema, + type InstalledPlugin, + type PluginCatalogEntry, + type CapabilityCall, + type CapabilityEvent, + type PluginPermission, + type PluginUiSlot, + type UpdatePluginConfigInput +} from "@memmy/local-api-contracts"; +import { pluginEligibility } from "./plugin-rules.js"; +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"; +import { noopPluginSkillManager, type PluginSkillManager } from "../adapters/outbound/plugin-skill/index.js"; +import { + createPluginLocalArtifactService, + type HostedPluginArtifact, + type PluginLocalArtifactService +} from "./plugin-local-artifact-service.js"; +import type { PluginRecord, PluginRepository } from "../infrastructure/app-state-store/repositories/plugin-repo.js"; +import type { SecretStore } from "../infrastructure/app-state-store/index.js"; + +export type { PluginArtifactManager } from "../adapters/outbound/plugin-artifact/index.js"; +export type { PluginRuntimeHost } from "../adapters/outbound/plugin-runtime/index.js"; + +export interface PluginService { + list(): InstalledPlugin[]; + catalog(): Promise; + suspend(id: string): Promise; + get(id: string): InstalledPlugin; + readUi(id: string, slot: PluginUiSlot): Promise; + openArtifact(id: string, token: string): Promise; + install(pluginId: string, version?: string): Promise; + update(id: string, version?: string): Promise; + configure(id: string, input: UpdatePluginConfigInput): InstalledPlugin; + approvePermissions(id: string, permissions: PluginPermission[]): Promise; + enable(id: string): Promise; + disable(id: string): Promise; + uninstall(id: string): Promise; + invoke(call: CapabilityCall): AsyncIterable; + cancel(id: string, callId: string): Promise; + respond(id: string, callId: string, interactionId: string, response: unknown): Promise; + restoreActive(): Promise; + shutdown(): Promise; +} + +export interface CreatePluginServiceOptions { + repository: PluginRepository; + secretStore: SecretStore; + registry: PluginRegistry; + runtimeHost: PluginRuntimeHost; + artifactManager: PluginArtifactManager; + skillManager?: PluginSkillManager; + onSkillContentInvalidated?: (pluginId?: string) => void; + localArtifactService?: PluginLocalArtifactService; + /** Resolves whether the signed-in account holds a manifest-declared entitlement. */ + isAuthenticated?: () => boolean; + isEntitlementGranted?: (entitlement: string) => boolean; + /** Private plugin IDs discoverable through server-issued account entitlements. */ + catalogPluginIds?: () => readonly string[]; + /** Runtime-wide barrier: plugin tools and Skills are shared across Agent turns. */ + hasActiveAgentTasks?: (signal: AbortSignal) => Promise; +} + +export function createPluginService(options: CreatePluginServiceOptions): PluginService { + const skillManager = options.skillManager ?? noopPluginSkillManager; + const localArtifacts = options.localArtifactService ?? createPluginLocalArtifactService(); + const isEntitlementGranted = options.isEntitlementGranted ?? (() => false); + // Enforced here rather than only in the reconciler so a direct local-API call cannot bypass it. + const eligibility = (manifest: PluginRecord["manifest"]) => pluginEligibility(manifest, options.isAuthenticated?.() ?? false, isEntitlementGranted); + const activeCalls = new Map(); + const suspended = new Set(); + const updates = new Map }>(); + const idleWaiters = new Map void; reject: (error: Error) => void }>(); + let closing = false; + const shutdown = new AbortController(); + const withActivity = (plugin: InstalledPlugin): InstalledPlugin => ({ + ...plugin, activeCallCount: activeCalls.get(plugin.id) ?? 0, + ...(updates.has(plugin.id) ? { update: { status: updates.get(plugin.id)!.status, version: updates.get(plugin.id)!.version } } : {}) + }); + const assertEntitled = (manifest: PluginRecord["manifest"]) => { + if (!eligibility(manifest).canUse) throw pluginError("plugin_entitlement_required", `Plugin access denied: ${manifest.id}`); + const entitlement = manifest.requiredEntitlement; + if (!entitlement || isEntitlementGranted(entitlement)) return; + throw pluginError( + "plugin_entitlement_required", + `Plugin requires an account entitlement that is not granted: ${manifest.id}` + ); + }; + const required = (id: string) => { + const plugin = options.repository.get(id); + if (!plugin) throw pluginError("plugin_unavailable", `Plugin not found: ${id}`); + return plugin; + }; + const activateContributions = async (plugin: PluginRecord, secrets: Readonly>) => { + await options.runtimeHost.activate(plugin, secrets); + try { + await skillManager.activate(plugin); + } catch (error) { + await options.runtimeHost.deactivate(plugin.id).catch(() => undefined); + await skillManager.deactivate(plugin.id).catch(() => undefined); + throw error; + } + }; + const deactivateContributions = async (pluginId: string) => { + options.onSkillContentInvalidated?.(pluginId); + const results = await Promise.allSettled([ + options.runtimeHost.deactivate(pluginId), + skillManager.deactivate(pluginId) + ]); + const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (failure) throw failure.reason; + }; + + return { + list() { + return options.repository.list().map((plugin) => withActivity({ ...publicPlugin(plugin), eligibility: eligibility(plugin.manifest) })); + }, + async catalog() { + const listed = await options.registry.list?.() ?? []; + const manifests = new Map(listed.map((manifest) => [manifest.id, manifest])); + const privatePluginIds = [...new Set(options.catalogPluginIds?.() ?? [])] + .filter((pluginId) => !manifests.has(pluginId)); + const privateReleases = await Promise.allSettled( + privatePluginIds.map((pluginId) => options.registry.resolve(pluginId)) + ); + for (const result of privateReleases) { + if (result.status === "fulfilled") { + manifests.set(result.value.manifest.id, result.value.manifest); + } + } + return [...manifests.values()].filter((manifest) => eligibility(manifest).visible).map((manifest) => ({ + manifest, eligibility: eligibility(manifest), + ...(options.repository.get(manifest.id) ? { installed: this.list().find((plugin) => plugin.id === manifest.id) } : {}) + })); + }, + async suspend(id) { + suspended.add(id); + if (!activeCalls.get(id)) await this.disable(id); + }, + + get(id) { + return withActivity(publicPlugin(required(id))); + }, + + async readUi(id, slot) { + const plugin = required(id); + const entry = plugin.manifest.ui?.[slot]?.entry; + if (!entry) throw pluginError("plugin_unavailable", `Plugin has no UI ${slot}: ${id}`); + return options.artifactManager.readTextFile(plugin, entry, 512 * 1024); + }, + + async openArtifact(id, token) { + required(id); + return localArtifacts.open(id, token); + }, + + async install(pluginId, version) { + const release = await options.registry.resolve(pluginId, version); + const manifest = PluginManifestSchema.parse(release.manifest); + if (manifest.id !== pluginId || (version && manifest.version !== version)) { + throw pluginError("plugin_invalid", "Registry returned a different plugin release"); + } + if (!options.runtimeHost.supports(manifest.runtime.adapter)) { + throw pluginError("plugin_adapter_unsupported", `Unsupported plugin adapter: ${manifest.runtime.adapter}`); + } + assertEntitled(manifest); + + const existing = options.repository.get(pluginId); + if (existing) { + if (existing.version === manifest.version && manifestsEqual(existing.manifest, manifest)) { + assertReleaseDigest(existing, release.artifact?.sha256); + return publicPlugin(existing); + } + throw pluginError("conflict", `Plugin is already installed: ${pluginId}@${existing.version}`); + } + + const artifact = await options.artifactManager.install({ ...release, manifest }); + try { + return publicPlugin(options.repository.save({ + manifest, + state: manifest.permissions.length ? "pending_approval" : "installed", + ...artifact + })); + } catch (error) { + await options.artifactManager.remove(artifact); + throw error; + } + }, + + async update(id, version) { + if (closing) throw pluginError("plugin_unavailable", "Plugin host is shutting down"); + required(id); + const current = updates.get(id); + if (current) { + if (current.version !== version) throw pluginError("conflict", "An update to another version is already pending"); + return current.promise; + } + const entry = { status: "waiting" as "waiting" | "installing", version, promise: undefined as unknown as Promise }; + updates.set(id, entry); + entry.promise = Promise.resolve().then(async () => { + try { + // A gap between calls is not a task boundary. Keep serving the old + // version until the Agent turn AND all capability iterators finish. + for (;;) { + while (activeCalls.get(id)) { + if (closing) throw pluginError("plugin_unavailable", "Plugin host is shutting down"); + await new Promise((resolve, reject) => idleWaiters.set(id, { resolve, reject })); + } + if (closing) throw pluginError("plugin_unavailable", "Plugin host is shutting down"); + const busy = await options.hasActiveAgentTasks?.(shutdown.signal) ?? false; + if (closing) throw pluginError("plugin_unavailable", "Plugin host is shutting down"); + // A call can start while the runtime status request is in flight. + if (!busy && !activeCalls.get(id)) break; + await delay(1_000, undefined, { signal: shutdown.signal }); + } + entry.status = "installing"; + const previous = required(id); + const release = await options.registry.resolve(id, version); + const manifest = PluginManifestSchema.parse(release.manifest); + if (manifest.id !== id || (version && manifest.version !== version)) { + throw pluginError("plugin_invalid", "Registry returned a different plugin release"); + } + if (!options.runtimeHost.supports(manifest.runtime.adapter)) { + throw pluginError("plugin_adapter_unsupported", `Unsupported plugin adapter: ${manifest.runtime.adapter}`); + } + assertEntitled(manifest); + if (previous.version === manifest.version) { + if (!manifestsEqual(previous.manifest, manifest)) { + throw pluginError("conflict", `Plugin release manifest changed: ${id}@${manifest.version}`); + } + assertReleaseDigest(previous, release.artifact?.sha256); + return publicPlugin(previous); + } + + const artifact = await options.artifactManager.install({ ...release, manifest }); + const approvedPermissions = previous.approvedPermissions.filter((approved) => + manifest.permissions.some((declared) => permissionKey(approved) === permissionKey(declared)) + ); + const pendingApproval = !samePermissions(approvedPermissions, manifest.permissions); + const draft: PluginRecord = { + ...previous, + version: manifest.version, + manifest, + approvedPermissions, + state: pendingApproval ? "pending_approval" : "disabled", + ...artifact + }; + try { + validateConfig(draft, draft.config); + const secrets = pendingApproval ? {} : readSecrets(draft, options.secretStore); + if (previous.state === "active") await deactivateContributions(id); + let updated = options.repository.save({ + manifest, + state: draft.state, + ...artifact + }); + updated = options.repository.setApprovedPermissions(id, approvedPermissions); + if (previous.state === "active" && !pendingApproval) { + await activateContributions(updated, secrets); + updated = options.repository.setState(id, "active"); + } + if (artifact.rootPath !== previous.rootPath) await options.artifactManager.remove(previous); + return publicPlugin(updated); + } catch (error) { + await deactivateContributions(id).catch(() => undefined); + let restored = options.repository.save({ + manifest: previous.manifest, + state: previous.state === "active" ? "disabled" : previous.state, + artifactHash: previous.artifactHash, + rootPath: previous.rootPath + }); + restored = options.repository.setApprovedPermissions(id, previous.approvedPermissions); + restored = options.repository.setConfig(id, previous.config); + let rollbackError: unknown; + if (previous.state === "active") { + try { + await activateContributions(restored, readSecrets(restored, options.secretStore)); + options.repository.setState(id, "active"); + } catch (restoreError) { + rollbackError = restoreError; + options.repository.setState(id, "failed", `Update rollback failed: ${errorMessage(restoreError)}`); + } + } + if (artifact.rootPath !== previous.rootPath) await options.artifactManager.remove(artifact); + if (rollbackError) { + throw pluginError( + "plugin_activation_failed", + `Plugin update failed (${errorMessage(error)}) and rollback failed (${errorMessage(rollbackError)})` + ); + } + throw error; + } + } finally { + updates.delete(id); + idleWaiters.delete(id); + } + }); + return entry.promise; + }, + + configure(id, input) { + const plugin = required(id); + if (plugin.state === "active" || plugin.state === "enabling" || plugin.state === "disabling") { + throw pluginError("conflict", "Disable plugin before changing its configuration"); + } + validateConfig(plugin, input.config); + const allowedSecrets = declaredSecretKeys(plugin.manifest.permissions); + for (const [key, secret] of Object.entries(input.secrets ?? {})) { + if (!allowedSecrets.has(key)) { + throw pluginError("plugin_permission_denied", `Secret is not declared by plugin: ${key}`); + } + if (!secret) throw pluginError("plugin_invalid", `Secret cannot be empty: ${key}`); + options.secretStore.set(secretRef(id, key), secret); + } + return publicPlugin(options.repository.setConfig(id, input.config)); + }, + + async approvePermissions(id, permissions) { + const plugin = required(id); + const approved = permissions.map((permission) => PluginPermissionSchema.parse(permission)); + assertPermissionSubset(approved, plugin.manifest.permissions); + if (plugin.state === "active" && !samePermissions(approved, plugin.approvedPermissions)) { + options.repository.setState(id, "disabling"); + try { + await deactivateContributions(id); + options.repository.setState(id, "disabled"); + } catch (error) { + options.repository.setState(id, "failed", errorMessage(error)); + throw pluginError("plugin_runtime_error", errorMessage(error)); + } + } + const updated = options.repository.setApprovedPermissions(id, approved); + const next = updated.state === "pending_approval" && hasAllPermissions(updated) + ? options.repository.setState(id, "installed") + : updated; + return publicPlugin(next); + }, + + async enable(id) { + suspended.delete(id); + const plugin = required(id); + if (plugin.state === "active") return publicPlugin(plugin); + assertEntitled(plugin.manifest); + if (!hasAllPermissions(plugin)) { + throw pluginError("plugin_permission_denied", "Plugin permissions have not been approved"); + } + validateConfig(plugin, plugin.config); + const secrets = readSecrets(plugin, options.secretStore); + options.repository.setState(id, "enabling"); + try { + await activateContributions(plugin, secrets); + return publicPlugin(options.repository.setState(id, "active")); + } catch (error) { + options.repository.setState(id, "failed", errorMessage(error)); + throw pluginError("plugin_activation_failed", errorMessage(error)); + } + }, + + async disable(id) { + const plugin = required(id); + if (plugin.state !== "active" && plugin.state !== "enabling" && plugin.state !== "failed") { + return publicPlugin(options.repository.setState(id, "disabled")); + } + options.repository.setState(id, "disabling"); + try { + await deactivateContributions(id); + return publicPlugin(options.repository.setState(id, "disabled")); + } catch (error) { + options.repository.setState(id, "failed", errorMessage(error)); + throw pluginError("plugin_runtime_error", errorMessage(error)); + } + }, + + async uninstall(id) { + if (updates.has(id)) throw pluginError("conflict", "Wait for the pending plugin update before uninstalling"); + options.onSkillContentInvalidated?.(id); + let plugin = required(id); + if (plugin.state === "active" || plugin.state === "enabling" || plugin.state === "failed") { + await this.disable(id); + plugin = required(id); + } + await options.artifactManager.remove(plugin); + await skillManager.deactivate(id); + localArtifacts.revokePlugin(id); + for (const key of declaredSecretKeys(plugin.manifest.permissions)) { + options.secretStore.delete(secretRef(id, key)); + } + options.repository.delete(id); + }, + + async *invoke(rawCall) { + const call = CapabilityCallSchema.parse(rawCall); + const plugin = required(call.pluginId); + if (updates.get(plugin.id)?.status === "installing") throw pluginError("conflict", "Plugin update is being installed; retry after completion"); + if (plugin.state !== "active") throw pluginError("plugin_unavailable", `Plugin is not active: ${plugin.id}`); + // Covers the window between a grant being revoked and the reconciler disabling the plugin. + assertEntitled(plugin.manifest); + if (!hasAllPermissions(plugin)) throw pluginError("plugin_permission_denied", "Plugin permissions have changed"); + if (!plugin.manifest.capabilities.some((capability) => capability.id === call.capabilityId)) { + throw pluginError("plugin_unavailable", `Capability not found: ${call.capabilityId}`); + } + activeCalls.set(plugin.id, (activeCalls.get(plugin.id) ?? 0) + 1); + const startedAt = Date.now(); + let outcome: "success" | "error" | "interrupted" = "interrupted"; + let errorCode: string | null = null; + const publishedPaths = new Map(); + 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; + } + 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) { + outcome = "error"; + errorCode = typeof (error as { code?: unknown })?.code === "string" + ? (error as { code: string }).code + : "plugin_runtime_error"; + throw error; + } finally { + activeCalls.set(plugin.id, (activeCalls.get(plugin.id) ?? 1) - 1); + if (!activeCalls.get(plugin.id)) { + try { if (suspended.has(plugin.id)) await this.disable(plugin.id); } + finally { idleWaiters.get(plugin.id)?.resolve(); } + } + try { + options.repository.recordCall({ + callId: call.callId, + pluginId: plugin.id, + pluginVersion: plugin.version, + capabilityId: call.capabilityId, + adapterId: plugin.manifest.runtime.adapter, + durationMs: Date.now() - startedAt, + outcome, + errorCode + }); + } catch (error) { + console.warn(`Plugin call audit write failed: ${errorMessage(error)}`); + } + } + }, + + async cancel(id, callId) { + required(id); + await options.runtimeHost.cancel(id, callId); + }, + + async respond(id, callId, interactionId, response) { + required(id); + await options.runtimeHost.respond(id, callId, interactionId, response); + }, + + async restoreActive() { + for (const plugin of options.repository.list().filter((candidate) => candidate.state === "active" || candidate.state === "enabling")) { + try { + if (!eligibility(plugin.manifest).canUse) { options.repository.setState(plugin.id, "disabled"); continue; } + assertEntitled(plugin.manifest); + if (!hasAllPermissions(plugin)) throw new Error("Plugin permissions have changed"); + validateConfig(plugin, plugin.config); + await activateContributions(plugin, readSecrets(plugin, options.secretStore)); + if (plugin.state === "enabling") options.repository.setState(plugin.id, "active"); + } catch (error) { + options.repository.setState(plugin.id, "failed", errorMessage(error)); + } + } + }, + + async shutdown() { + closing = true; + shutdown.abort(); + for (const waiter of idleWaiters.values()) waiter.reject(pluginError("plugin_unavailable", "Plugin host is shutting down")); + options.onSkillContentInvalidated?.(); + await Promise.allSettled(options.repository.list() + .filter((plugin) => plugin.state === "active" || plugin.state === "enabling") + .map((plugin) => deactivateContributions(plugin.id))); + } + }; +} + +function capabilityTaskId(input: unknown): string | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return undefined; + const taskId = (input as Record).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): 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; +} + +function validateConfig(plugin: PluginRecord, config: Record): void { + if (!plugin.manifest.configSchema) return; + const validate = new Ajv({ allErrors: true, strict: false }).compile(plugin.manifest.configSchema); + if (!validate(config)) { + throw pluginError("plugin_invalid", `Invalid plugin config: ${validate.errors?.[0]?.message ?? "unknown error"}`); + } +} + +function declaredSecretKeys(permissions: PluginPermission[]): Set { + return new Set(permissions.flatMap((permission) => permission.type === "secret" ? permission.keys : [])); +} + +function readSecrets(plugin: PluginRecord, secretStore: SecretStore): Record { + return Object.fromEntries([...declaredSecretKeys(plugin.manifest.permissions)].map((key) => { + const value = secretStore.get(secretRef(plugin.id, key)); + if (value === null) throw pluginError("plugin_invalid", `Missing plugin secret: ${key}`); + return [key, value]; + })); +} + +function secretRef(pluginId: string, key: string): string { + return `plugin:${pluginId}:${key}`; +} + +function hasAllPermissions(plugin: Pick): boolean { + return samePermissions(plugin.approvedPermissions, plugin.manifest.permissions); +} + +function assertPermissionSubset(approved: PluginPermission[], declared: PluginPermission[]): void { + const declaredKeys = new Set(declared.map(permissionKey)); + const invalid = approved.find((permission) => !declaredKeys.has(permissionKey(permission))); + if (invalid) throw pluginError("plugin_permission_denied", "Cannot approve a permission the plugin did not declare"); +} + +function samePermissions(left: PluginPermission[], right: PluginPermission[]): boolean { + return left.length === right.length && left.every((permission) => + right.some((candidate) => permissionKey(candidate) === permissionKey(permission)) + ); +} + +function permissionKey(permission: PluginPermission): string { + switch (permission.type) { + case "network": return JSON.stringify([permission.type, [...permission.hosts].sort()]); + case "filesystem": return JSON.stringify([permission.type, permission.access, [...permission.paths].sort()]); + case "secret": return JSON.stringify([permission.type, [...permission.keys].sort()]); + case "host-service": return JSON.stringify([permission.type, [...permission.services].sort()]); + } +} + +function manifestsEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function assertReleaseDigest(plugin: Pick, sha256?: string): void { + if (plugin.artifactHash !== (sha256?.toLowerCase() ?? null)) { + throw pluginError("conflict", `Plugin release digest changed: ${plugin.id}@${plugin.version}`); + } +} + +function pluginError(code: string, message: string): Error { + return Object.assign(new Error(message), { code }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/App/backend/src/services/plugin-skill-content-service.ts b/App/backend/src/services/plugin-skill-content-service.ts new file mode 100644 index 000000000..996f01154 --- /dev/null +++ b/App/backend/src/services/plugin-skill-content-service.ts @@ -0,0 +1,104 @@ +/** Private plugin instructions: bounded cloud reads and memory-only, account-scoped caching. */ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import type { InstalledPlugin } from "@memmy/local-api-contracts"; + +export { PluginSkillRefSchema as SkillRefSchema } from "@memmy/local-api-contracts"; +import { PluginSkillRefSchema as SkillRefSchema, type PluginSkillRef } from "@memmy/local-api-contracts"; +const Bundle = z.object({ + format: z.literal("memmy-plugin-skill-v1"), + pluginId: z.string(), skillId: z.string(), pluginVersion: z.string(), revision: z.string(), + files: z.record(z.string(), z.string()) +}).strict(); +const MAX_BYTES = 512 * 1024; +export interface PluginSkillContentService { + reference(pluginId: string, skillId: string): PluginSkillRef; + resolve(ref: PluginSkillRef, signal?: AbortSignal): Promise; + clear(pluginId?: string): void; +} +export function createPluginSkillContentService(options: { + baseUrl: string; + getPlugin(id: string): InstalledPlugin | null; + authHeaders(): Record; + fetch?: typeof fetch; + ttlMs?: number; +}): PluginSkillContentService { + 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("Skill service requires HTTPS"); + const cache = new Map }>(); + const pending = new Map>(); + let account = ""; + let generation = 0; + const service: PluginSkillContentService = { + clear(pluginId) { + generation++; + for (const [key, value] of cache) if (!pluginId || value.pluginId === pluginId) { + clearTimeout(value.timer); cache.delete(key); + } + pending.clear(); + }, + reference(pluginId, skillId) { + const plugin = options.getPlugin(pluginId); + if (!plugin || plugin.state !== "active" || plugin.eligibility?.canUse === false || !options.authHeaders().authorization) throw failure("plugin_skill_access_denied"); + const skill = plugin.manifest.skills?.find(s => s.id === skillId); + if (!skill?.cloud) throw failure("plugin_skill_unavailable"); + return SkillRefSchema.parse({ pluginId, skillId, pluginVersion: plugin.version, ...skill.cloud }); + }, + async resolve(rawRef, signal) { + const ref = SkillRefSchema.parse(rawRef); + const current = service.reference(ref.pluginId, ref.skillId); + if (JSON.stringify(current) !== JSON.stringify(ref)) throw failure("plugin_skill_version_changed"); + const headers = options.authHeaders(); + if (!headers.authorization) throw failure("plugin_skill_access_denied"); + const identity = createHash("sha256").update(headers.authorization).digest("hex"); + if (account !== identity) { service.clear(); account = identity; } + const key = identity + JSON.stringify(ref); + signal?.throwIfAborted(); + const cached = cache.get(key); + if (cached && cached.expires > Date.now()) return cached.text; + signal?.throwIfAborted(); + let request = pending.get(key); + if (!request) { + const epoch = generation; + request = (async () => { + const url = new URL(`/api/v1/plugins/${encodeURIComponent(ref.pluginId)}/skills/${encodeURIComponent(ref.skillId)}/bundle`, base); + url.searchParams.set("pluginVersion", ref.pluginVersion); url.searchParams.set("revision", ref.revision); + // Shared request has its own deadline; one cancelled consumer must not cancel other consumers. + const response = await (options.fetch ?? fetch)(url, { headers: { ...headers, accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(15_000) }); + if (!response.ok) throw failure(response.status === 401 || response.status === 403 ? "plugin_skill_access_denied" : "plugin_skill_unavailable"); + if (!response.body || Number(response.headers.get("content-length")) > MAX_BYTES) throw failure("plugin_skill_invalid"); + const chunks: Uint8Array[] = []; let size = 0; + for await (const chunk of response.body) { size += chunk.byteLength; if (size > MAX_BYTES) throw failure("plugin_skill_invalid"); chunks.push(chunk); } + const bytes = Buffer.concat(chunks); + if (createHash("sha256").update(bytes).digest("hex") !== ref.sha256) throw failure("plugin_skill_invalid"); + let bundle: z.infer; + try { bundle = Bundle.parse(JSON.parse(bytes.toString("utf8"))); } catch { throw failure("plugin_skill_invalid"); } + if (bundle.pluginId !== ref.pluginId || bundle.skillId !== ref.skillId || bundle.pluginVersion !== ref.pluginVersion || bundle.revision !== ref.revision + || !bundle.files["SKILL.md"] || Object.keys(bundle.files).length > 20 + || Object.keys(bundle.files).some(p => !/^(SKILL\.md|references\/[a-zA-Z0-9_-]+\.md)$/.test(p))) throw failure("plugin_skill_invalid"); + const text = Object.entries(bundle.files).sort(([a], [b]) => a.localeCompare(b)).map(([path, content]) => `### ${path}\n${content}`).join("\n\n"); + // Recheck after network I/O: a logout, update or disable cannot repopulate stale data. + if (epoch !== generation || options.authHeaders().authorization !== headers.authorization + || JSON.stringify(service.reference(ref.pluginId, ref.skillId)) !== JSON.stringify(ref)) throw failure("plugin_skill_access_denied"); + const ttl = options.ttlMs ?? 600_000; + const timer = setTimeout(() => cache.delete(key), ttl); timer.unref?.(); + if (cache.size >= 64 && !cache.has(key)) { + const oldestKey = cache.keys().next().value; + if (oldestKey) { clearTimeout(cache.get(oldestKey)!.timer); cache.delete(oldestKey); } + } + const previous = cache.get(key); if (previous) clearTimeout(previous.timer); + cache.set(key, { pluginId: ref.pluginId, text, expires: Date.now() + ttl, timer }); + return text; + })().catch(() => { throw failure("plugin_skill_unavailable"); }).finally(() => { if (pending.get(key) === request) pending.delete(key); }); + pending.set(key, request); + } + const text = await request; + signal?.throwIfAborted(); + // Never hand the caller stale content after an account or plugin change. + if (options.authHeaders().authorization !== headers.authorization || JSON.stringify(service.reference(ref.pluginId, ref.skillId)) !== JSON.stringify(ref)) throw failure("plugin_skill_access_denied"); + return text; + } + }; + return service; +} +function failure(code: string) { return Object.assign(new Error(code), { code }); } diff --git a/App/backend/src/services/progress-bus.ts b/App/backend/src/services/progress-bus.ts index 7b2ccfcd9..9687bdd65 100644 --- a/App/backend/src/services/progress-bus.ts +++ b/App/backend/src/services/progress-bus.ts @@ -1,6 +1,6 @@ /** Progress bus module. */ import { EventEmitter } from "node:events"; -import type { ScanPhase, ScanResult } from "@memmy/local-api-contracts"; +import type { PluginCapabilityEventPayload, ScanPhase, ScanResult } from "@memmy/local-api-contracts"; /** Contract for agent source scan progress event. */ export interface AgentSourceScanProgressEvent { @@ -23,10 +23,13 @@ export interface AgentSourceScanCompletedEvent { export interface ProgressBusEventMap { "agent_source.scan_progress": AgentSourceScanProgressEvent; "agent_source.scan_completed": AgentSourceScanCompletedEvent; + "plugin.capability_event": PluginCapabilityEventPayload; } /** Contract for progress bus. */ export interface ProgressBus { + pendingPluginEvents(): PluginCapabilityEventPayload[]; + acknowledgePluginInteraction(pluginId: string, callId: string, interactionId: string): void; emit( eventName: EventName, event: ProgressBusEventMap[EventName] @@ -40,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..bee8a309c --- /dev/null +++ b/App/backend/src/services/tests/account-plugin-integration.test.ts @@ -0,0 +1,92 @@ +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", + rules: { installation: "automatic" }, + 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/asr-stream-service.test.ts b/App/backend/src/services/tests/asr-stream-service.test.ts new file mode 100644 index 000000000..73f7781cc --- /dev/null +++ b/App/backend/src/services/tests/asr-stream-service.test.ts @@ -0,0 +1,248 @@ +/** Asr stream relay tests. */ +import { once } from "node:events"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import WebSocket, { WebSocketServer } from "ws"; +import { afterEach, describe, expect, it } from "vitest"; +import { createAsrStreamService, toCloudStreamUrl } from "../asr-stream-service.js"; + +/** + * A stand-in for the Cloud's `/agentAsr/stream`: records what it was sent + * and lets each test script what it answers. + */ +interface CloudStub { + url: string; + /** Every connection the stub accepted, oldest first. */ + connections: Array<{ + headers: Record; + frames: Array<{ data: Buffer; binary: boolean }>; + socket: WebSocket; + }>; + close(): Promise; +} + +async function startCloudStub(): Promise { + const server = createServer(); + const wss = new WebSocketServer({ server, path: "/api/agentAsr/stream" }); + const connections: CloudStub["connections"] = []; + wss.on("connection", (socket, request) => { + const entry: CloudStub["connections"][number] = { headers: request.headers, frames: [], socket }; + connections.push(entry); + socket.on("message", (raw, isBinary) => { + entry.frames.push({ data: Buffer.isBuffer(raw) ? raw : Buffer.from(raw as ArrayBuffer), binary: isBinary }); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + connections, + async close() { + for (const client of wss.clients) client.terminate(); + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + } + }; +} + +/** A loopback HTTP server that hands its upgrades to the relay, standing in for the local API. */ +async function startRelayHost(relay: ReturnType): Promise<{ url: string; server: Server }> { + const server = createServer(); + server.on("upgrade", (request, socket, head) => relay.handleUpgrade(request, socket, head)); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return { url: `ws://127.0.0.1:${(server.address() as AddressInfo).port}/api/asr/stream`, server }; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("timed out waiting for condition"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function nextMessage(socket: WebSocket): Promise { + return new Promise((resolve) => socket.once("message", (raw) => resolve(JSON.parse(raw.toString())))); +} + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +describe("toCloudStreamUrl", () => { + it("maps the Cloud HTTPS base to its wss stream endpoint behind the /api gateway prefix", () => { + // cloudBaseUrl never carries /api itself (resolveCloudServiceBaseUrl + // returns the bare origin) — every Cloud call adds the prefix per-path, + // same as /api/agentAsr/transcriptions and /api/agentUser/login do. + expect(toCloudStreamUrl("https://test-api.memmy.cn")).toBe("wss://test-api.memmy.cn/api/agentAsr/stream"); + expect(toCloudStreamUrl("https://test-api.memmy.cn/")).toBe("wss://test-api.memmy.cn/api/agentAsr/stream"); + }); + + it("keeps plain ws for a loopback stub", () => { + expect(toCloudStreamUrl("http://127.0.0.1:8080")).toBe("ws://127.0.0.1:8080/api/agentAsr/stream"); + }); +}); + +describe("asr stream relay", () => { + it("bridges control, audio and events between the renderer and the Cloud with the account credential", async () => { + const cloud = await startCloudStub(); + cleanups.push(() => cloud.close()); + const relay = createAsrStreamService({ + bootstrapRepository: { getAppSettings: () => ({ userMode: "account" }) }, + accountSessionRepository: { getCloudUuid: () => "cloud-uuid-1" }, + cloudBaseUrl: cloud.url + }); + cleanups.push(() => relay.close()); + const host = await startRelayHost(relay); + cleanups.push(() => new Promise((resolve) => host.server.close(() => resolve()))); + + const renderer = new WebSocket(host.url); + cleanups.push(() => renderer.terminate()); + await once(renderer, "open"); + + renderer.send(JSON.stringify({ type: "start", sampleRate: 16_000 })); + renderer.send(Buffer.from([1, 0, 2, 0, 3, 0]), { binary: true }); + renderer.send(JSON.stringify({ type: "finish" })); + + await waitFor(() => cloud.connections[0]?.frames.length === 3); + const upstream = cloud.connections[0]!; + // The Cloud sees the account bearer, never the local runtime token. + expect(upstream.headers.authorization).toBe("Bearer cloud-uuid-1"); + expect(upstream.frames.map((frame) => frame.binary)).toEqual([false, true, false]); + expect(JSON.parse(upstream.frames[0]!.data.toString())).toEqual({ type: "start", sampleRate: 16_000 }); + expect([...upstream.frames[1]!.data]).toEqual([1, 0, 2, 0, 3, 0]); + expect(JSON.parse(upstream.frames[2]!.data.toString())).toEqual({ type: "finish" }); + + // Events flow back down unchanged. + const eventPromise = nextMessage(renderer); + upstream.socket.send(JSON.stringify({ type: "sentence", sentenceId: 1, text: "你好", beginMs: 0, endMs: 900 })); + expect(await eventPromise).toEqual({ type: "sentence", sentenceId: 1, text: "你好", beginMs: 0, endMs: 900 }); + + const finished = nextMessage(renderer); + upstream.socket.send(JSON.stringify({ type: "finished" })); + expect(await finished).toEqual({ type: "finished" }); + }); + + it("holds frames sent before the upstream handshake completes and replays them in order", async () => { + const cloud = await startCloudStub(); + cleanups.push(() => cloud.close()); + const relay = createAsrStreamService({ + bootstrapRepository: { getAppSettings: () => ({ userMode: "account" }) }, + accountSessionRepository: { getCloudUuid: () => "cloud-uuid-1" }, + cloudBaseUrl: cloud.url + }); + cleanups.push(() => relay.close()); + const host = await startRelayHost(relay); + cleanups.push(() => new Promise((resolve) => host.server.close(() => resolve()))); + + const renderer = new WebSocket(host.url); + cleanups.push(() => renderer.terminate()); + await once(renderer, "open"); + // Fire everything at once, before the relay could possibly have finished + // its own handshake with the stub. + renderer.send(JSON.stringify({ type: "start" })); + renderer.send(Buffer.from([9, 9]), { binary: true }); + renderer.send(Buffer.from([8, 8]), { binary: true }); + + await waitFor(() => cloud.connections[0]?.frames.length === 3); + const frames = cloud.connections[0]!.frames; + expect(frames.map((frame) => frame.binary)).toEqual([false, true, true]); + expect([...frames[1]!.data]).toEqual([9, 9]); + expect([...frames[2]!.data]).toEqual([8, 8]); + }); + + it("refuses the stream in BYOK mode without contacting the Cloud", async () => { + const cloud = await startCloudStub(); + cleanups.push(() => cloud.close()); + const relay = createAsrStreamService({ + bootstrapRepository: { getAppSettings: () => ({ userMode: "byok" }) }, + accountSessionRepository: { getCloudUuid: () => "cloud-uuid-1" }, + cloudBaseUrl: cloud.url + }); + cleanups.push(() => relay.close()); + const host = await startRelayHost(relay); + cleanups.push(() => new Promise((resolve) => host.server.close(() => resolve()))); + + const renderer = new WebSocket(host.url); + cleanups.push(() => renderer.terminate()); + const event = await nextMessage(renderer); + const [code] = (await once(renderer, "close")) as [number]; + + expect(event).toEqual({ type: "error", message: "Live transcription requires account mode" }); + expect(code).toBe(1008); + expect(cloud.connections).toHaveLength(0); + }); + + it("refuses the stream when no account is signed in", async () => { + const cloud = await startCloudStub(); + cleanups.push(() => cloud.close()); + const relay = createAsrStreamService({ + bootstrapRepository: { getAppSettings: () => ({ userMode: "account" }) }, + accountSessionRepository: { getCloudUuid: () => null }, + cloudBaseUrl: cloud.url + }); + cleanups.push(() => relay.close()); + const host = await startRelayHost(relay); + cleanups.push(() => new Promise((resolve) => host.server.close(() => resolve()))); + + const renderer = new WebSocket(host.url); + cleanups.push(() => renderer.terminate()); + const event = await nextMessage(renderer); + + expect(event).toEqual({ type: "error", message: "Cloud account is not authenticated" }); + expect(cloud.connections).toHaveLength(0); + }); + + it("answers a malformed control frame locally and does not forward it", async () => { + const cloud = await startCloudStub(); + cleanups.push(() => cloud.close()); + const relay = createAsrStreamService({ + bootstrapRepository: { getAppSettings: () => ({ userMode: "account" }) }, + accountSessionRepository: { getCloudUuid: () => "cloud-uuid-1" }, + cloudBaseUrl: cloud.url + }); + cleanups.push(() => relay.close()); + const host = await startRelayHost(relay); + cleanups.push(() => new Promise((resolve) => host.server.close(() => resolve()))); + + const renderer = new WebSocket(host.url); + cleanups.push(() => renderer.terminate()); + await once(renderer, "open"); + await waitFor(() => cloud.connections.length === 1); + + const errorEvent = nextMessage(renderer); + renderer.send(JSON.stringify({ type: "teleport" })); + expect(await errorEvent).toEqual({ type: "error", message: "invalid control frame" }); + + renderer.send(JSON.stringify({ type: "finish" })); + await waitFor(() => cloud.connections[0]?.frames.length === 1); + expect(JSON.parse(cloud.connections[0]!.frames[0]!.data.toString())).toEqual({ type: "finish" }); + }); + + it("closes the upstream task when the renderer goes away", async () => { + const cloud = await startCloudStub(); + cleanups.push(() => cloud.close()); + const relay = createAsrStreamService({ + bootstrapRepository: { getAppSettings: () => ({ userMode: "account" }) }, + accountSessionRepository: { getCloudUuid: () => "cloud-uuid-1" }, + cloudBaseUrl: cloud.url + }); + cleanups.push(() => relay.close()); + const host = await startRelayHost(relay); + cleanups.push(() => new Promise((resolve) => host.server.close(() => resolve()))); + + const renderer = new WebSocket(host.url); + await once(renderer, "open"); + await waitFor(() => cloud.connections.length === 1); + const upstreamClosed = once(cloud.connections[0]!.socket, "close"); + + renderer.close(1000, "user stopped"); + const [code, reason] = (await upstreamClosed) as [number, Buffer]; + + expect(code).toBe(1000); + expect(reason.toString()).toBe("user stopped"); + }); +}); diff --git a/App/backend/src/services/tests/bundled-plugin-bootstrap-service.test.ts b/App/backend/src/services/tests/bundled-plugin-bootstrap-service.test.ts new file mode 100644 index 000000000..a72ad817c --- /dev/null +++ b/App/backend/src/services/tests/bundled-plugin-bootstrap-service.test.ts @@ -0,0 +1,226 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { InstalledPlugin } from "@memmy/local-api-contracts"; +import { createInMemoryPluginRegistry, type PluginRelease } from "../../adapters/outbound/plugin-registry/index.js"; +import { createAppStateStore, type AppStateStore } from "../../infrastructure/app-state-store/index.js"; +import { + reconcileBundledPlugins, + suppressBundledPlugins +} from "../bundled-plugin-bootstrap-service.js"; +import { createPluginService, type PluginService } from "../plugin-service.js"; + +let root: string | undefined; +let store: AppStateStore | undefined; + +afterEach(() => { + store?.close(); + store = undefined; + if (root) rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +const manifest = { + apiVersion: "memmy/v1" as const, + id: "literature-review", + name: "Literature Review", + version: "0.5.17", + runtime: { adapter: "command" as const, config: { command: "dist/runtime.js" } }, + capabilities: [], + permissions: [{ type: "network" as const, hosts: ["arxiv.org"] }] +}; + +function installed(version = manifest.version): InstalledPlugin { + return { + id: manifest.id, + name: manifest.name, + version, + manifest: { ...manifest, version }, + state: "installed", + approvedPermissions: [], + config: {}, + lastError: null, + createdAt: "2026-09-11T00:00:00.000Z", + updatedAt: "2026-09-11T00:00:00.000Z" + }; +} + +function createPersistedContext() { + root = mkdtempSync(join(tmpdir(), "memmy-bundled-plugin-")); + const databasePath = join(root, "app.sqlite"); + store = createAppStateStore({ databasePath }); + const release: PluginRelease = { + manifest: { + ...manifest, + capabilities: [{ + id: "run", name: "Run", description: "Run review", + inputSchema: { type: "object" }, outputSchema: { type: "object" }, execution: "request" + }] + } + }; + const releases: PluginRelease[] = [release]; + const artifactManager = { + install: vi.fn(async () => ({ artifactHash: null, rootPath: null })), + remove: vi.fn(async () => undefined), + readTextFile: vi.fn(async () => "") + }; + const createService = () => createPluginService({ + repository: store!.repositories.plugins, + secretStore: store!.secretStore, + registry: createInMemoryPluginRegistry(releases), + artifactManager, + runtimeHost: { + supports: () => true, + activate: async () => undefined, + deactivate: async () => undefined, + async *invoke() { yield { type: "result", output: {} }; } + } + }); + let service = createService(); + return { + get service() { return service; }, + releases, + artifactManager, + bootstrap: () => reconcileBundledPlugins({ + plugins: service, + releases: releases.map(({ manifest: item }) => ({ id: item.id, version: item.version })), + enabledById: {}, + userUninstalledIds: new Set(store!.repositories.plugins.listUserUninstalledIds()) + }), + async restart() { + await service.shutdown(); + store!.close(); + store = createAppStateStore({ databasePath }); + service = createService(); + } + }; +} + +describe("reconcileBundledPlugins", () => { + it("keeps an explicit uninstall across restarts and restores defaults after reinstall", async () => { + const context = createPersistedContext(); + await expect(context.bootstrap()).resolves.toEqual([]); + expect(context.service.get(manifest.id).state).toBe("active"); + + await context.service.uninstall(manifest.id); + await context.restart(); + context.releases.push({ manifest: { ...context.releases[0].manifest, id: "another-bundled-plugin" } }); + await expect(context.bootstrap()).resolves.toEqual([]); + expect(context.service.list().map((plugin) => plugin.id)).toEqual(["another-bundled-plugin"]); + + await context.service.install(manifest.id); + await context.restart(); + await expect(context.bootstrap()).resolves.toEqual([]); + expect(context.service.get(manifest.id).state).toBe("active"); + expect(store!.repositories.plugins.listUserUninstalledIds()).toEqual([]); + }); + + it("preserves the uninstall preference when explicit reinstallation fails", async () => { + const context = createPersistedContext(); + await context.bootstrap(); + await context.service.uninstall(manifest.id); + context.artifactManager.install.mockRejectedValueOnce(new Error("download failed")); + + await expect(context.service.install(manifest.id)).rejects.toThrow("download failed"); + await context.restart(); + await expect(context.bootstrap()).resolves.toEqual([]); + expect(context.service.list()).toEqual([]); + expect(store!.repositories.plugins.listUserUninstalledIds()).toEqual([manifest.id]); + }); + + it("keeps the installed choice when uninstall cleanup fails", async () => { + const context = createPersistedContext(); + await context.bootstrap(); + context.artifactManager.remove.mockRejectedValueOnce(new Error("remove failed")); + + await expect(context.service.uninstall(manifest.id)).rejects.toThrow("remove failed"); + expect(store!.repositories.plugins.listUserUninstalledIds()).toEqual([]); + await context.restart(); + await expect(context.bootstrap()).resolves.toEqual([]); + expect(context.service.get(manifest.id).state).toBe("active"); + }); + + it("installs, auto-approves, and enables a trusted release by default", async () => { + const plugin = installed(); + const service = { + list: vi.fn(() => []), + install: vi.fn(async () => plugin), + update: vi.fn(), + approvePermissions: vi.fn(async () => plugin), + enable: vi.fn(async () => ({ ...plugin, state: "active" })), + disable: vi.fn() + } as unknown as PluginService; + + await expect(reconcileBundledPlugins({ + plugins: service, + releases: [{ id: manifest.id, version: manifest.version }], + enabledById: {} + })).resolves.toEqual([]); + expect(service.install).toHaveBeenCalledWith(manifest.id, manifest.version); + expect(service.approvePermissions).toHaveBeenCalledWith(manifest.id, manifest.permissions); + expect(service.enable).toHaveBeenCalledWith(manifest.id); + expect(service.disable).not.toHaveBeenCalled(); + }); + + it("preserves the installed version without automatic upgrade", async () => { + const previous = installed("0.5.16"); + const current = installed(); + const service = { + list: vi.fn(() => [previous]), + install: vi.fn(), + update: vi.fn(async () => current), + approvePermissions: vi.fn(async () => current), + enable: vi.fn(), + disable: vi.fn(async () => ({ ...current, state: "disabled" })), + uninstall: vi.fn() + } as unknown as PluginService; + + await reconcileBundledPlugins({ + plugins: service, + releases: [{ id: manifest.id, version: manifest.version }], + enabledById: { [manifest.id]: false } + }); + expect(service.update).not.toHaveBeenCalled(); + expect(service.disable).toHaveBeenCalledWith(manifest.id); + expect(service.uninstall).not.toHaveBeenCalled(); + }); +}); + +describe("suppressBundledPlugins", () => { + it("deactivates a persisted active plugin omitted by this distribution", async () => { + const active = { ...installed(), state: "active" as const }; + const service = { + list: vi.fn(() => [active]), + disable: vi.fn(async () => ({ ...active, state: "disabled" as const })), + uninstall: vi.fn() + } as unknown as PluginService; + + await expect(suppressBundledPlugins({ + plugins: service, + releases: [], + managedPluginIds: [manifest.id], + enabledById: {} + })).resolves.toEqual([]); + + expect(service.disable).toHaveBeenCalledWith(manifest.id); + expect(service.uninstall).not.toHaveBeenCalled(); + }); + + it("keeps an included and enabled plugin active", async () => { + const active = { ...installed(), state: "active" as const }; + const service = { + list: vi.fn(() => [active]), + disable: vi.fn() + } as unknown as PluginService; + + await suppressBundledPlugins({ + plugins: service, + releases: [{ id: manifest.id, version: manifest.version }], + managedPluginIds: [manifest.id], + enabledById: { [manifest.id]: true } + }); + + expect(service.disable).not.toHaveBeenCalled(); + }); +}); diff --git a/App/backend/src/services/tests/plugin-account-tool-sync.test.ts b/App/backend/src/services/tests/plugin-account-tool-sync.test.ts new file mode 100644 index 000000000..84ce1b11e --- /dev/null +++ b/App/backend/src/services/tests/plugin-account-tool-sync.test.ts @@ -0,0 +1,103 @@ +import { PluginManifestSchema } from "@memmy/local-api-contracts"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDefaultAgentAdapterRegistry } from "../../adapters/outbound/agent-adapter/index.js"; +import type { MemmyAgentAdminClient } from "../../adapters/outbound/memmy-agent-admin-client/index.js"; +import { createInMemoryPluginRegistry } from "../../adapters/outbound/plugin-registry/index.js"; +import { createAppStateStore, type AppStateStore } from "../../infrastructure/app-state-store/index.js"; +import { createMemmyConfigWriter } from "../../infrastructure/memmy-config/index.js"; +import { createPermissionManager } from "../../permission/index.js"; +import { createMockCloudClient } from "../../tests/support/mock-cloud-client.js"; +import { createMockMemoryClient } from "../../tests/support/mock-memory-client.js"; +import { createBackendServices, type BackendServices } from "../index.js"; + +let root: string; +let store: AppStateStore | undefined; +let services: BackendServices | undefined; +afterEach(async () => { + await services?.plugins.shutdown(); + store?.close(); + services = undefined; + store = undefined; + vi.unstubAllEnvs(); + if (root) rmSync(root, { recursive: true, force: true }); +}); + +function setup() { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-account-tools-")); + vi.stubEnv("MEMMY_AGENT_WORKSPACE", join(root, "workspace")); + vi.stubEnv("MEMMY_AGENT_DATA_DIR", root); + store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + const reloadMcpConfig = vi.fn(async () => ({ ok: true, message: "reloaded", requires_restart: false })); + const manifest = PluginManifestSchema.parse({ + apiVersion: "memmy/v1", id: "literature-review", name: "Review", version: "0.5.26", + runtime: { adapter: "http", config: { baseUrl: "https://plugin.example" } }, + rules: { distribution: "public", installation: "automatic", requiresLogin: true }, + capabilities: [{ id: "review_create_task", name: "Create task", description: "Create a review", + inputSchema: { type: "object" }, outputSchema: { type: "object" }, execution: "request" }], + permissions: [] + }); + services = createBackendServices({ + appStateStore: store, + agentAdapterRegistry: createDefaultAgentAdapterRegistry(), + permissionManager: createPermissionManager({ appStateStore: store }), + cloudClient: createMockCloudClient(), memoryClient: createMockMemoryClient(), accountChannel: "email", + memmyConfigWriter: createMemmyConfigWriter({ configPath: join(root, "config.yaml"), accountChannel: "email" }), + memmyConfigPath: join(root, "config.yaml"), + memmyAgentAdminClient: { reloadMcpConfig } as unknown as MemmyAgentAdminClient, + pluginRegistry: createInMemoryPluginRegistry([{ manifest }]), + pluginArtifactManager: { + install: async () => ({ artifactHash: null, rootPath: null }), + readTextFile: async () => "", remove: async () => undefined + }, + pluginRuntimeHost: { + supports: () => true, activate: async () => undefined, deactivate: async () => undefined, + async *invoke() { yield { type: "result", output: {} }; }, + cancel: async () => undefined, respond: async () => undefined + } + }); + const login = () => services!.account.verifyCode({ channel: "email", email: "test@example.com", code: "123456" }); + return { reloadMcpConfig, login }; +} + +describe("account plugin tool synchronization", () => { + it("publishes auto-install, logout and restored eligibility without reloading on unchanged profile polls", async () => { + const { reloadMcpConfig, login } = setup(); + await login(); + expect(services!.plugins.get("literature-review").state).toBe("active"); + expect(reloadMcpConfig).toHaveBeenCalledTimes(1); + await services!.account.getSession(); + expect(reloadMcpConfig).toHaveBeenCalledTimes(1); + await services!.account.logout(); + expect(services!.plugins.get("literature-review").state).toBe("disabled"); + expect(reloadMcpConfig).toHaveBeenCalledTimes(2); + await login(); + expect(services!.plugins.get("literature-review").state).toBe("active"); + expect(reloadMcpConfig).toHaveBeenCalledTimes(3); + }); + + it("retries a failed tool publication on the next account refresh", async () => { + const { reloadMcpConfig, login } = setup(); + reloadMcpConfig.mockResolvedValueOnce({ ok: false, message: "agent busy", requires_restart: false }); + await login(); + expect(services!.plugins.get("literature-review").state).toBe("active"); + await services!.account.getSession(); + expect(reloadMcpConfig).toHaveBeenCalledTimes(2); + await services!.account.getSession(); + expect(reloadMcpConfig).toHaveBeenCalledTimes(2); + }); + + it("preserves login and retries when the agent reload transport throws", async () => { + const { reloadMcpConfig, login } = setup(); + reloadMcpConfig.mockRejectedValueOnce(new Error("agent unavailable")); + await login(); + expect(services!.plugins.get("literature-review").state).toBe("active"); + await services!.account.getSession(); + expect(reloadMcpConfig).toHaveBeenCalledTimes(2); + await services!.account.getSession(); + expect(reloadMcpConfig).toHaveBeenCalledTimes(2); + }); + +}); diff --git a/App/backend/src/services/tests/plugin-asr-service.test.ts b/App/backend/src/services/tests/plugin-asr-service.test.ts new file mode 100644 index 000000000..2e14bb9d4 --- /dev/null +++ b/App/backend/src/services/tests/plugin-asr-service.test.ts @@ -0,0 +1,124 @@ +/** Plugin `asr` host-service tests. */ +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AsrTranscriptionInput } from "@memmy/local-api-contracts"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createPluginAsrService } from "../plugin-asr-service.js"; +import { createPluginHostServiceRouter } from "../plugin-host-service-router.js"; + +describe("plugin asr host service", () => { + let root: string; + let outside: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-asr-")); + outside = mkdtempSync(join(tmpdir(), "memmy-private-")); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + }); + + it("transcribes an upload and forwards diarization", async () => { + const audioPath = join(root, "interview.m4a"); + writeFileSync(audioPath, Buffer.from("fake-audio")); + const received: AsrTranscriptionInput[] = []; + const service = createPluginAsrService({ + audioRoots: [root], + asr: { + transcribe: async (input) => { + received.push(input); + return { + text: "hello", + modelId: "qwen3-asr-flash", + provider: "dashscope", + source: "account", + transcribedAt: "2026-06-15T10:00:00.000Z", + segments: [{ text: "hello", speakerId: 0, startMs: 0, endMs: 500 }] + }; + } + } + }); + + const response = await service.invoke(call({ path: audioPath, diarization: true })); + + expect(received[0]).toMatchObject({ + mimeType: "audio/mp4", + diarization: true, + audioBase64: Buffer.from("fake-audio").toString("base64") + }); + expect(response).toMatchObject({ text: "hello", segments: [{ speakerId: 0, text: "hello" }] }); + }); + + it("rejects a path outside the Host-owned audio roots", async () => { + const audioPath = join(outside, "private.wav"); + writeFileSync(audioPath, Buffer.from("private")); + const service = createPluginAsrService({ audioRoots: [root], asr: unreachableAsr() }); + + await expect(service.invoke(call({ path: audioPath }))).rejects.toMatchObject({ code: "forbidden" }); + }); + + it("rejects a symlink inside an audio root that escapes it", async () => { + const target = join(outside, "private.wav"); + writeFileSync(target, Buffer.from("private")); + symlinkSync(target, join(root, "linked.wav")); + const service = createPluginAsrService({ audioRoots: [root], asr: unreachableAsr() }); + + await expect(service.invoke(call({ path: join(root, "linked.wav") }))).rejects.toMatchObject({ code: "forbidden" }); + }); + + it("rejects an empty recording", async () => { + const audioPath = join(root, "empty.wav"); + writeFileSync(audioPath, Buffer.alloc(0)); + const service = createPluginAsrService({ audioRoots: [root], asr: unreachableAsr() }); + + await expect(service.invoke(call({ path: audioPath }))).rejects.toMatchObject({ code: "invalid_argument" }); + }); + + it("routes only the asr service and rejects unknown services", async () => { + const audioPath = join(root, "interview.wav"); + writeFileSync(audioPath, Buffer.from("audio")); + const router = createPluginHostServiceRouter([ + { services: ["model-inference"], invoker: { invoke: async () => "inference" } }, + { + services: ["asr"], + invoker: createPluginAsrService({ + audioRoots: [root], + asr: { + transcribe: async () => ({ + text: "ok", + modelId: "qwen3-asr-flash", + provider: "dashscope", + source: "account", + transcribedAt: "2026-06-15T10:00:00.000Z" + }) + } + }) + } + ]); + + await expect(router.invoke({ ...call({ path: audioPath }), service: "model-inference" })).resolves.toBe("inference"); + await expect(router.invoke(call({ path: audioPath }))).resolves.toMatchObject({ text: "ok" }); + await expect(router.invoke({ ...call({ path: audioPath }), service: "file-input" })) + .rejects.toMatchObject({ code: "host_service_unavailable" }); + }); +}); + +function call(input: Record) { + return { + pluginId: "vertical-demo", + callId: "call-1", + conversationId: "conversation-1", + service: "asr", + input + }; +} + +function unreachableAsr() { + return { + transcribe: async () => { + throw new Error("transcription should not be reached"); + } + }; +} 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-entitlement-reconcile-service.test.ts b/App/backend/src/services/tests/plugin-entitlement-reconcile-service.test.ts new file mode 100644 index 000000000..10142b156 --- /dev/null +++ b/App/backend/src/services/tests/plugin-entitlement-reconcile-service.test.ts @@ -0,0 +1,206 @@ +/** Entitlement-gated plugin reconciliation tests. */ +import { PluginRulesSchema, type InstalledPlugin, type PluginManifest } from "@memmy/local-api-contracts"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginService } from "../plugin-service.js"; +import { entitledPluginIds, reconcileEntitledPlugins } from "../plugin-entitlement-reconcile-service.js"; + +describe("entitled plugin reconciliation", () => { + it("installs, approves, and enables a newly granted plugin", async () => { + const plugins = fakePluginService([], new Set(), ["vertical-demo"]); + const failures = await reconcileEntitledPlugins({ + plugins: plugins.service, + entitlements: ["plugin:vertical-demo", "unrelated:grant"] + }); + + expect(failures).toEqual([]); + expect(plugins.calls).toEqual([ + "install:vertical-demo", + "approvePermissions:vertical-demo", + "enable:vertical-demo" + ]); + }); + + it("preserves an installed plugin until a manual update", async () => { + const plugins = fakePluginService([installed("vertical-demo", "active")], new Set(), ["vertical-demo"]); + await reconcileEntitledPlugins({ plugins: plugins.service, entitlements: ["plugin:vertical-demo"] }); + + // Already active, so no redundant enable is issued. + expect(plugins.calls).toEqual(["approvePermissions:vertical-demo"]); + }); + + it("disables a plugin whose grant was revoked without uninstalling its data", async () => { + const plugins = fakePluginService([installed("vertical-demo", "active")]); + await reconcileEntitledPlugins({ plugins: plugins.service, entitlements: [] }); + + expect(plugins.calls).toEqual(["disable:vertical-demo"]); + }); + + it("leaves plugins that declare no entitlement untouched", async () => { + const plugins = fakePluginService([{ ...installed("open-plugin", "active"), manifest: manifest("open-plugin", null) }]); + await reconcileEntitledPlugins({ plugins: plugins.service, entitlements: [] }); + + expect(plugins.calls).toEqual([]); + }); + + it("keeps an entitled plugin active when its grant is still held", async () => { + const plugins = fakePluginService([installed("vertical-demo", "active")]); + await reconcileEntitledPlugins({ plugins: plugins.service, entitlements: ["plugin:vertical-demo"] }); + + expect(plugins.calls).not.toContain("disable:vertical-demo"); + }); + + it("reports a failing plugin without aborting the remaining ones", async () => { + const plugins = fakePluginService([], new Set(["broken"]), ["broken", "healthy"]); + const failures = await reconcileEntitledPlugins({ + plugins: plugins.service, + entitlements: ["plugin:broken", "plugin:healthy"] + }); + + expect(failures).toEqual([{ pluginId: "broken", message: "registry unavailable" }]); + expect(plugins.calls).toContain("enable:healthy"); + }); + + it("pins first installation to the advertised version but never upgrades existing installations", async () => { + const plugins = fakePluginService([], new Set(), ["vertical-demo"]); + await reconcileEntitledPlugins({ plugins: plugins.service, entitlements: ["plugin:vertical-demo"] }); + expect(plugins.service.install).toHaveBeenCalledWith("vertical-demo", "2.0.0"); + expect(plugins.service.update).not.toHaveBeenCalled(); + }); + + it("ignores legacy autoUpdate=true, absent catalogs and failed catalog lookups for existing plugins", async () => { + for (const mode of ["legacy", "absent", "offline"]) { + const plugins = fakePluginService([installed("vertical-demo", "active")], new Set(), mode === "legacy" ? ["vertical-demo"] : []); + if (mode === "legacy") vi.spyOn(plugins.service, "catalog").mockResolvedValue([{ + manifest: { ...manifest("vertical-demo"), version: "2.0.0", rules: PluginRulesSchema.parse({ autoUpdate: true, installation: "automatic" }) }, + eligibility: { visible: true, canInstall: true, canUse: true, reason: null } + }]); + if (mode === "offline") vi.spyOn(plugins.service, "catalog").mockRejectedValue(new Error("offline")); + await reconcileEntitledPlugins({ plugins: plugins.service, entitlements: ["plugin:vertical-demo"] }); + expect(plugins.service.update).not.toHaveBeenCalled(); + expect(plugins.service.install).not.toHaveBeenCalled(); + expect(plugins.service.list()[0].version).toBe("1.0.0"); + } + }); + + it("leaves a queued manual update untouched during account reconciliation", async () => { + const plugins = fakePluginService([{ ...installed("vertical-demo", "active"), update: { status: "waiting", version: "2.0.0" } }]); + await reconcileEntitledPlugins({ plugins: plugins.service, entitlements: ["plugin:vertical-demo"] }); + expect(plugins.calls).toEqual([]); + }); + + it("installs an entitled private plugin omitted from the public catalog", async () => { + const plugins = fakePluginService(); + await reconcileEntitledPlugins({ plugins: plugins.service, entitlements: ["plugin:vertical-demo"] }); + expect(plugins.calls).toEqual([ + "install:vertical-demo", + "approvePermissions:vertical-demo", + "enable:vertical-demo" + ]); + expect(plugins.service.install).toHaveBeenCalledWith("vertical-demo"); + }); + + it("still reconciles entitlement-only plugins when the public catalog is unavailable", async () => { + const plugins = fakePluginService(); + vi.spyOn(plugins.service, "catalog").mockRejectedValue(new Error("offline")); + const failures = await reconcileEntitledPlugins({ plugins: plugins.service, entitlements: ["plugin:vertical-demo"] }); + expect(failures).toEqual([{ pluginId: "catalog", message: "Error: offline" }]); + expect(plugins.calls).toEqual([ + "install:vertical-demo", + "approvePermissions:vertical-demo", + "enable:vertical-demo" + ]); + }); + + it("does not reinstall an entitled private plugin the user explicitly removed", async () => { + const plugins = fakePluginService(); + await reconcileEntitledPlugins({ + plugins: plugins.service, + entitlements: ["plugin:vertical-demo"], + userUninstalledIds: new Set(["vertical-demo"]) + }); + expect(plugins.calls).toEqual([]); + }); + + it("ignores grants that are not plugin grants", () => { + expect(entitledPluginIds(["plugin:a", "feature:b", "plugin:", "plugin:a"])).toEqual(["a"]); + }); +}); + +function manifest(id: string, requiredEntitlement: string | null = `plugin:${id}`): PluginManifest { + return { + apiVersion: "memmy/v1", + id, + name: id, + version: "1.0.0", + runtime: { adapter: "command", config: { command: "bin/run" } }, + capabilities: [{ + id: "run", + name: "Run", + description: "Runs the vertical workflow", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "request" + }], + permissions: [], + ...(requiredEntitlement ? { requiredEntitlement } : {}) + } as PluginManifest; +} + +function installed(id: string, state: InstalledPlugin["state"]): InstalledPlugin { + return { + id, + version: "1.0.0", + manifest: manifest(id), + state, + approvedPermissions: [], + config: {}, + lastError: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + }; +} + +function fakePluginService(initial: InstalledPlugin[] = [], failing: ReadonlySet = new Set(), catalogIds: string[] = []) { + const calls: string[] = []; + const state = new Map(initial.map((plugin) => [plugin.id, plugin])); + const record = (action: string, id: string) => { + calls.push(`${action}:${id}`); + if (failing.has(id)) throw new Error("registry unavailable"); + }; + + const service = { + list: () => [...state.values()], + catalog: async () => catalogIds.map((id) => ({ + manifest: { ...manifest(id), version: "2.0.0", rules: { installation: "automatic" } }, + eligibility: { visible: true, canInstall: true, canUse: true, reason: null } + })), + install: vi.fn(async (id: string) => { + record("install", id); + const plugin = installed(id, "installed"); + state.set(id, plugin); + return plugin; + }), + update: vi.fn(async (id: string) => { + record("update", id); + return state.get(id)!; + }), + approvePermissions: async (id: string) => { + record("approvePermissions", id); + return state.get(id)!; + }, + enable: async (id: string) => { + record("enable", id); + const plugin = { ...state.get(id)!, state: "active" as const }; + state.set(id, plugin); + return plugin; + }, + disable: async (id: string) => { + record("disable", id); + const plugin = { ...state.get(id)!, state: "disabled" as const }; + state.set(id, plugin); + return plugin; + } + } as unknown as PluginService; + + return { service, calls }; +} diff --git a/App/backend/src/services/tests/plugin-local-artifact-service.test.ts b/App/backend/src/services/tests/plugin-local-artifact-service.test.ts new file mode 100644 index 000000000..0a1da70d2 --- /dev/null +++ b/App/backend/src/services/tests/plugin-local-artifact-service.test.ts @@ -0,0 +1,189 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPluginLocalArtifactService } from "../plugin-local-artifact-service.js"; + +let root: string | undefined; +afterEach(() => { if (root) rmSync(root, { recursive: true, force: true }); root = undefined; }); + +describe("PluginLocalArtifactService", () => { + it("turns an approved local file into opaque preview and download URIs", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-output-")); + const output = join(root, "task", "review.pdf"); + mkdirSync(join(root, "task")); + writeFileSync(output, "pdf"); + const service = createPluginLocalArtifactService(); + const hosted = await service.host({ + id: "literature-review", + approvedPermissions: [ + { type: "filesystem", paths: [join(root, "task")], access: "read-write" }, + { type: "host-service", services: ["artifact-host"] } + ], + config: {} + }, { id: "review", name: "review.pdf", mediaType: "application/pdf", uri: pathToFileURL(output).href }); + expect(hosted.uri).toMatch(/^\/api\/v1\/plugins\/literature-review\/artifacts\/[^/]+\/preview$/); + expect(hosted.downloadUri).toMatch(/\/download$/); + const token = hosted.uri.split("/").at(-2)!; + await expect(service.open("literature-review", token)).resolves.toMatchObject({ path: realpathSync(output), name: "review.pdf" }); + }); + + it("rejects local files outside approved filesystem roots", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-output-")); + const output = join(root, "review.pdf"); + const allowed = join(root, "allowed"); + mkdirSync(allowed); + writeFileSync(output, "pdf"); + await expect(createPluginLocalArtifactService().host({ + id: "literature-review", + approvedPermissions: [ + { type: "filesystem", paths: [allowed], access: "read-write" }, + { type: "host-service", services: ["artifact-host"] } + ], + config: {} + }, { id: "review", name: "review.pdf", mediaType: "application/pdf", uri: pathToFileURL(output).href })).rejects.toMatchObject({ code: "plugin_permission_denied" }); + }); + + it("accepts artifacts under the Host-approved plugin-data task root", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-output-")); + const pluginDataRoot = join(root, "plugin-data"); + const output = join(pluginDataRoot, "literature-review", "review.md"); + mkdirSync(join(pluginDataRoot, "literature-review"), { recursive: true }); + writeFileSync(output, "# Review"); + await expect(createPluginLocalArtifactService({ pluginDataRoot }).host({ + id: "literature-review", + approvedPermissions: [{ type: "host-service", services: ["plugin-data", "artifact-host"] }], + config: {} + }, { id: "review", name: "review.md", mediaType: "text/markdown", uri: pathToFileURL(output).href })).resolves.toMatchObject({ + uri: expect.stringContaining("/preview"), + downloadUri: expect.stringContaining("/download") + }); + }); + + it("publishes final artifacts into the bound conversation workspace", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-publish-")); + const pluginDataRoot = join(root, "plugin-data"); + const workspace = join(root, "workspace"); + const output = join(pluginDataRoot, "literature-review", "tasks", "review-1", "outputs", "review.md"); + mkdirSync(join(pluginDataRoot, "literature-review", "tasks", "review-1", "outputs"), { recursive: true }); + mkdirSync(workspace); + writeFileSync(output, "# Published review"); + const service = createPluginLocalArtifactService({ + pluginDataRoot, + resolveWorkspace: async (conversationId) => conversationId === "websocket:chat-1" ? workspace : null + }); + + const hosted = await service.host({ + id: "literature-review", + approvedPermissions: [{ type: "host-service", services: ["plugin-data", "artifact-host"] }], + config: {} + }, { + id: "review", + name: "review.md", + mediaType: "text/markdown", + uri: pathToFileURL(output).href + }, { + conversationId: "websocket:chat-1", + taskId: "review-1", + callId: "call-1" + }); + + const published = join(workspace, "outputs", "literature-review", "review-1", "review.md"); + expect(hosted.path).toBe(realpathSync(published)); + expect(readFileSync(published, "utf8")).toBe("# Published review"); + expect(readFileSync(output, "utf8")).toBe("# Published review"); + }); + + it("publishes another plugin's deliverables, keyed by conversation when it has no task", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-publish-any-")); + const pluginDataRoot = join(root, "plugin-data"); + const workspace = join(root, "workspace"); + const output = join(pluginDataRoot, "legal-labor", "sessions", "s1", "artifacts", "报告.docx"); + mkdirSync(dirname(output), { recursive: true }); + mkdirSync(workspace); + writeFileSync(output, "report"); + const service = createPluginLocalArtifactService({ + pluginDataRoot, + resolveWorkspace: async (conversationId) => conversationId === "websocket:chat-1" ? workspace : null + }); + + // No taskId: the legal plugin keys its work on sessionId, so the + // conversation scopes the output directory instead. + const hosted = await service.host({ + id: "legal-labor", + approvedPermissions: [{ type: "host-service", services: ["plugin-data", "artifact-host"] }], + config: {} + }, { + id: "report", name: "企业-用工风险与合规诊断报告.docx", mediaType: "application/vnd.ms-word", uri: pathToFileURL(output).href, role: "deliverable" + }, { conversationId: "websocket:chat-1", callId: "call-1" }); + + const published = join(workspace, "outputs", "legal-labor", "websocket-chat-1", "企业-用工风险与合规诊断报告.docx"); + expect(hosted.path).toBe(realpathSync(published)); + expect(readFileSync(published, "utf8")).toBe("report"); + }); + + it("keeps setup templates out of the outputs directory", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-publish-setup-")); + const pluginDataRoot = join(root, "plugin-data"); + const workspace = join(root, "workspace"); + const output = join(pluginDataRoot, "legal-labor", "sessions", "s1", "artifacts", "清单.xlsx"); + mkdirSync(dirname(output), { recursive: true }); + mkdirSync(workspace); + writeFileSync(output, "template"); + const service = createPluginLocalArtifactService({ + pluginDataRoot, + resolveWorkspace: async () => workspace + }); + + // A template is something the user fills in, not something the engagement + // produced, so it is served but not written into outputs/. + const hosted = await service.host({ + id: "legal-labor", + approvedPermissions: [{ type: "host-service", services: ["plugin-data", "artifact-host"] }], + config: {} + }, { + id: "checklist", name: "企业诊断前_资料准备清单.xlsx", mediaType: "application/vnd.ms-excel", uri: pathToFileURL(output).href, role: "setup" + }, { conversationId: "websocket:chat-1", callId: "call-1" }); + + expect(hosted.path).toBeUndefined(); + expect(existsSync(join(workspace, "outputs"))).toBe(false); + }); + + it("serves an artifact without publishing when the conversation has no workspace", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-publish-noworkspace-")); + const pluginDataRoot = join(root, "plugin-data"); + const output = join(pluginDataRoot, "legal-labor", "sessions", "s1", "artifacts", "报告.docx"); + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, "report"); + const service = createPluginLocalArtifactService({ + pluginDataRoot, + // Non-WebUI invocations have no conversation workspace. + resolveWorkspace: async () => null + }); + + const hosted = await service.host({ + id: "legal-labor", + approvedPermissions: [{ type: "host-service", services: ["plugin-data", "artifact-host"] }], + config: {} + }, { + id: "report", name: "报告.docx", mediaType: "application/vnd.ms-word", uri: pathToFileURL(output).href, role: "deliverable" + }, { conversationId: "websocket:chat-1", callId: "call-1" }); + + expect(hosted.path).toBeUndefined(); + expect(hosted.uri).toMatch(/\/preview$/); + }); + + it("does not treat a plugin-configured taskRoot as a Host-approved path", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-output-")); + const output = join(root, "review.md"); + writeFileSync(output, "# Review"); + await expect(createPluginLocalArtifactService().host({ + id: "literature-review", + approvedPermissions: [{ type: "host-service", services: ["plugin-data", "artifact-host"] }], + config: { taskRoot: root } + }, { id: "review", name: "review.md", mediaType: "text/markdown", uri: pathToFileURL(output).href })).rejects.toMatchObject({ + code: "plugin_permission_denied" + }); + }); +}); diff --git a/App/backend/src/services/tests/plugin-model-inference-service.test.ts b/App/backend/src/services/tests/plugin-model-inference-service.test.ts new file mode 100644 index 000000000..71c288da7 --- /dev/null +++ b/App/backend/src/services/tests/plugin-model-inference-service.test.ts @@ -0,0 +1,589 @@ +import type { ModelSelectionResolution } from "@memmy/local-api-contracts"; +import { describe, expect, it, vi } from "vitest"; +import { createPluginModelInferenceService } from "../plugin-model-inference-service.js"; + +describe("plugin model inference Host service", () => { + it.each([ + { code: 40308, data: null, message: "quota insufficient" }, + { error: { code: "40309", type: "insufficient_quota" } } + ])("reports account quota errors without JSON fallback or model retries: %j", async (body) => { + const account = resolved(); + if (!account.ok) throw new Error("fixture"); + account.context.protocol = "memmy-account"; + const fetch = vi.fn(async () => new Response(JSON.stringify(body))); + const service = createPluginModelInferenceService({ resolveModel: async () => account, fetch: fetch as typeof globalThis.fetch }); + await expect(service.invoke({ pluginId: "literature-review", callId: "quota", conversationId: "v", service: "model-inference", + input: { messages: [{ role: "user", content: "Write" }], responseFormat: "json" } + })).rejects.toMatchObject({ code: "model_quota_exhausted", retryable: false }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("uses the current user model without exposing credentials", async () => { + const fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.headers).toMatchObject({ authorization: "Bearer secret-key" }); + return new Response(JSON.stringify({ + choices: [{ message: { content: "{\"summary\":\"grounded\"}" }, finish_reason: "stop" }], + usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 } + }), { status: 200, headers: { "content-type": "application/json" } }); + }); + const resolveModel = vi.fn(async () => resolved()); + const service = createPluginModelInferenceService({ resolveModel, fetch: fetch as typeof globalThis.fetch }); + const result = await service.invoke({ + pluginId: "literature-review", callId: "call-1", conversationId: "conversation-1", + modelPreset: "account-deepseek-v4.1-flash", service: "model-inference", + input: { messages: [{ role: "user", content: "Summarize evidence" }], responseFormat: "json", maxOutputTokens: 500 } + }); + expect(result).toEqual({ + content: "{\"summary\":\"grounded\"}", finishReason: "stop", + usage: { inputTokens: 12, outputTokens: 4, totalTokens: 16 }, + model: { provider: "openai", model: "current-user-model" } + }); + expect(JSON.stringify(result)).not.toContain("secret-key"); + expect(resolveModel).toHaveBeenCalledWith("literature-review", "account-deepseek-v4.1-flash"); + expect(fetch).toHaveBeenCalledWith("https://models.example/v1/chat/completions", expect.any(Object)); + }); + + it("bounds Qwen reasoning per call and preserves unconfigured requests and provider defaults", async () => { + const model = resolved(); + if (!model.ok) throw new Error("fixture"); + model.context.model = "qwen3.8-flash"; + model.provider.extraBody = { reasoning_effort: "xhigh", max_tokens: 8192 }; + const bodies: Record[] = []; + const fetch = vi.fn(async (_url: unknown, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ choices: [{ message: { content: "{}" }, finish_reason: "stop" }], + usage: { completion_tokens: 800, completion_tokens_details: { reasoning_tokens: 600 } } })); + }); + const service = createPluginModelInferenceService({ resolveModel: async () => model, fetch: fetch as typeof globalThis.fetch }); + const call = { pluginId: "literature-review", callId: "bounded", conversationId: "v", service: "model-inference" }; + const input = { messages: [{ role: "user", content: "Return JSON" }], maxOutputTokens: 700 }; + const result = await service.invoke({ ...call, input: { ...input, thinkingBudgetTokens: 2048, timeoutMs: 180_000, maxAttempts: 1 } }); + expect(bodies[0]).toMatchObject({ thinking_budget: 2048, max_completion_tokens: 2748 }); + expect(bodies[0]).not.toHaveProperty("max_tokens"); + expect(bodies[0]).not.toHaveProperty("reasoning_effort"); + expect(result).toMatchObject({ usage: { reasoningTokens: 600 } }); + await service.invoke({ ...call, pluginId: "another-plugin", input }); + expect(bodies[1]).toMatchObject({ reasoning_effort: "xhigh", max_tokens: 8192 }); + expect(bodies[1]).not.toHaveProperty("thinking_budget"); + expect(bodies[1]).not.toHaveProperty("max_completion_tokens"); + expect(model.provider.extraBody).toEqual({ reasoning_effort: "xhigh", max_tokens: 8192 }); + model.context.model = "unrelated-chat-model"; + await service.invoke({ ...call, input: { ...input, thinkingBudgetTokens: 2048 } }); + expect(bodies[2]).toEqual({ ...bodies[1], model: "unrelated-chat-model" }); + }); + + it("disables Qwen thinking only for opted-in requests and cannot be overridden by shared defaults", async () => { + const model = resolved(); + if (!model.ok) throw new Error("fixture"); + model.context.model = "qwen3.8-flash"; + const defaults = { enable_thinking: true, thinking_budget: 32000, reasoning_effort: "xhigh", thinking: { type: "enabled" }, + max_tokens: 32768, max_completion_tokens: 65536, chat_template_kwargs: { enable_thinking: true, keep: "value" } }; + model.provider.extraBody = structuredClone(defaults); + const bodies: Record[] = []; + const fetch = vi.fn(async (_url: unknown, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ choices: [{ message: { content: "{\"paragraph\":\"Synthetic prose\"}" }, finish_reason: "stop" }], + usage: { completion_tokens: 20, completion_tokens_details: { reasoning_tokens: 0 } } })); + }); + const service = createPluginModelInferenceService({ resolveModel: async () => model, fetch: fetch as typeof globalThis.fetch }); + const call = { pluginId: "literature-review", callId: "writing", conversationId: "v", service: "model-inference" }; + const input = { messages: [{ role: "user", content: "Write a paragraph" }], maxOutputTokens: 1100 }; + const result = await service.invoke({ ...call, input: { ...input, thinkingMode: "disabled", thinkingBudgetTokens: 2048 } }); + expect(bodies[0]).toMatchObject({ enable_thinking: false, max_completion_tokens: 1100, chat_template_kwargs: { enable_thinking: false, keep: "value" } }); + for (const key of ["thinking_budget", "reasoning_effort", "thinking", "max_tokens"]) expect(bodies[0]).not.toHaveProperty(key); + expect(result).toMatchObject({ usage: { reasoningTokens: 0 } }); + // Both unrelated work inside the review plugin and other plugins retain their settings. + await service.invoke({ ...call, input }); + await service.invoke({ ...call, pluginId: "other-plugin", input }); + expect(bodies[1]).toMatchObject(defaults); + expect(bodies[2]).toEqual(bodies[1]); + expect(model.provider.extraBody).toEqual(defaults); + model.context.model = "unrelated-chat-model"; + await service.invoke({ ...call, input: { ...input, thinkingMode: "disabled" } }); + expect(bodies[3]).toMatchObject(defaults); + model.context.model = "qwen3.6-max"; + await service.invoke({ ...call, input: { ...input, thinkingMode: "disabled" } }); + expect(bodies[4]).toMatchObject(defaults); + }); + + it.each([ + { + name: "DeepSeek V4", + provider: "deepseek", + endpoint: "https://api.deepseek.example/v1", + model: "deepseek-v4-flash", + expected: { thinking: { type: "disabled" }, max_tokens: 1100 }, + absent: ["enable_thinking", "reasoning_effort", "max_completion_tokens"] + }, + { + name: "GLM", + provider: "zhipu", + endpoint: "https://open.bigmodel.cn/api/paas/v4", + model: "glm-5-flash", + expected: { thinking: { type: "disabled" }, max_tokens: 1100 }, + absent: ["enable_thinking", "reasoning_effort", "max_completion_tokens"] + }, + { + name: "GPT with disable support", + provider: "openai", + endpoint: "https://api.openai.com/v1", + model: "gpt-5.1", + expected: { reasoning_effort: "none", max_completion_tokens: 1100 }, + absent: ["enable_thinking", "thinking", "max_tokens", "temperature"] + }, + { + name: "OpenRouter", + provider: "openai", + endpoint: "https://openrouter.ai/api/v1", + model: "anthropic/claude-sonnet-4", + expected: { reasoning: { effort: "none" }, max_tokens: 1100 }, + absent: ["enable_thinking", "thinking", "reasoning_effort", "max_completion_tokens"] + } + ])("translates disabled thinking for $name without leaking shared reasoning defaults", async ({ provider, endpoint, model, expected, absent }) => { + const selection = resolved(); + if (!selection.ok) throw new Error("fixture"); + selection.context.provider = provider; + selection.context.model = model; + selection.provider.provider = provider; + selection.provider.apiBase = endpoint; + selection.provider.extraBody = { + reasoning_effort: "high", + reasoning: { effort: "high" }, + thinking: { type: "enabled" }, + enable_thinking: true, + max_tokens: 32_768, + max_completion_tokens: 65_536 + }; + let body: Record = {}; + const fetch = vi.fn(async (_url: unknown, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ + choices: [{ message: { content: "{\"paragraph\":\"Synthetic prose\"}" }, finish_reason: "stop" }] + })); + }); + const service = createPluginModelInferenceService({ resolveModel: async () => selection, fetch: fetch as typeof globalThis.fetch }); + await service.invoke({ + pluginId: "literature-review", callId: "disable-thinking", conversationId: "v", service: "model-inference", + input: { messages: [{ role: "user", content: "Write" }], thinkingMode: "disabled", maxOutputTokens: 1100 } + }); + expect(body).toMatchObject(expected); + for (const key of absent) expect(body).not.toHaveProperty(key); + }); + + it("uses protocol-native disabled thinking controls for Claude, Gemini, and OpenAI Responses", async () => { + const call = { pluginId: "literature-review", callId: "native-thinking", conversationId: "v", service: "model-inference" }; + const input = { messages: [{ role: "user" as const, content: "Write" }], thinkingMode: "disabled" as const, maxOutputTokens: 900 }; + + const anthropic = resolved(); + if (!anthropic.ok) throw new Error("fixture"); + anthropic.context.protocol = "anthropic-messages"; + anthropic.context.model = "claude-sonnet-4"; + anthropic.provider.protocol = "anthropic-messages"; + anthropic.provider.extraBody = { thinking: { type: "enabled", budget_tokens: 4000 }, max_tokens: 8000 }; + let anthropicBody: Record = {}; + const anthropicFetch = vi.fn(async (_url: unknown, init?: RequestInit) => { + anthropicBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ content: [{ type: "text", text: "done" }], stop_reason: "end_turn" })); + }); + await createPluginModelInferenceService({ + resolveModel: async () => anthropic, + fetch: anthropicFetch as typeof globalThis.fetch + }).invoke({ ...call, input }); + expect(anthropicBody).toMatchObject({ max_tokens: 900, thinking: { type: "disabled" } }); + + const gemini = resolved(); + if (!gemini.ok) throw new Error("fixture"); + gemini.context.protocol = "gemini-generate-content"; + gemini.context.model = "gemini-2.5-flash"; + gemini.provider.protocol = "gemini-generate-content"; + gemini.provider.extraBody = { generationConfig: { topP: 0.8, thinkingConfig: { thinkingBudget: 4000 } } }; + let geminiBody: Record = {}; + const geminiFetch = vi.fn(async (_url: unknown, init?: RequestInit) => { + geminiBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ + candidates: [{ content: { parts: [{ text: "done" }] }, finishReason: "STOP" }] + })); + }); + await createPluginModelInferenceService({ + resolveModel: async () => gemini, + fetch: geminiFetch as typeof globalThis.fetch + }).invoke({ ...call, input }); + expect(geminiBody).toMatchObject({ + generationConfig: { topP: 0.8, maxOutputTokens: 900, thinkingConfig: { thinkingBudget: 0 } } + }); + + const responses = resolved(); + if (!responses.ok) throw new Error("fixture"); + responses.context.protocol = "openai-responses"; + responses.context.model = "gpt-5.1"; + responses.provider.protocol = "openai-responses"; + responses.provider.extraBody = { reasoning: { effort: "high" } }; + let responsesBody: Record = {}; + const responsesFetch = vi.fn(async (_url: unknown, init?: RequestInit) => { + responsesBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ output_text: "done", status: "completed" })); + }); + await createPluginModelInferenceService({ + resolveModel: async () => responses, + fetch: responsesFetch as typeof globalThis.fetch + }).invoke({ ...call, input }); + expect(responsesBody).toMatchObject({ + max_output_tokens: 900, + reasoning: { effort: "none" } + }); + }); + + it("does not duplicate a timed out paragraph request, and honors the caller deadline", async () => { + vi.useFakeTimers(); + try { + const fetch = vi.fn(async (_url: unknown, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + })); + const service = createPluginModelInferenceService({ resolveModel: async () => resolved(), fetch: fetch as typeof globalThis.fetch }); + const call = { pluginId: "literature-review", callId: "bounded", conversationId: "v", service: "model-inference", + input: { messages: [{ role: "user", content: "Write" }], timeoutMs: 180_000, maxAttempts: 1 } }; + let settled = false; + const pending = service.invoke(call).finally(() => { settled = true; }); + const assertion = expect(pending).rejects.toMatchObject({ code: "model_inference_timeout" }); + await vi.advanceTimersByTimeAsync(120_001); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(60_000); + await assertion; + expect(fetch).toHaveBeenCalledTimes(1); + const deadline = service.invoke({ ...call, deadline: new Date(Date.now() + 1500).toISOString() }); + const deadlineAssertion = expect(deadline).rejects.toMatchObject({ code: "model_inference_timeout" }); + await vi.advanceTimersByTimeAsync(1500); + await deadlineAssertion; + expect(fetch).toHaveBeenCalledTimes(2); + } finally { vi.useRealTimers(); } + }); + + it("rejects unavailable models and oversized requests before network access", async () => { + const fetch = vi.fn(); + const unavailable = createPluginModelInferenceService({ resolveModel: async () => null, fetch: fetch as typeof globalThis.fetch }); + await expect(unavailable.invoke({ pluginId: "p", callId: "c", conversationId: "v", service: "model-inference", input: { messages: [{ role: "user", content: "hi" }] } })).rejects.toMatchObject({ code: "model_unavailable" }); + const available = createPluginModelInferenceService({ resolveModel: async () => resolved(), fetch: fetch as typeof globalThis.fetch }); + await expect(available.invoke({ pluginId: "p", callId: "c", conversationId: "v", service: "model-inference", input: { messages: [{ role: "user", content: "x".repeat(200_001) }] } })).rejects.toBeDefined(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("uses one JSON compatibility fallback even when the plugin disables ordinary retries", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: "" }, finish_reason: "stop" }] + }), { status: 200, headers: { "content-type": "application/json" } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: "recovered" }, finish_reason: "stop" }] + }), { status: 200, headers: { "content-type": "application/json" } })); + const service = createPluginModelInferenceService({ + resolveModel: async () => resolved(), + fetch: fetch as typeof globalThis.fetch, + retryBaseDelayMs: 0 + }); + + await expect(service.invoke({ + pluginId: "literature-review", callId: "retry-empty", conversationId: "conversation-1", service: "model-inference", + input: { messages: [{ role: "user", content: "Check continuity" }], responseFormat: "json", maxAttempts: 1 } + })).resolves.toMatchObject({ content: "recovered" }); + expect(fetch).toHaveBeenCalledTimes(2); + const firstBody = JSON.parse(String(fetch.mock.calls[0]?.[1]?.body)); + const secondBody = JSON.parse(String(fetch.mock.calls[1]?.[1]?.body)); + expect(firstBody.response_format).toEqual({ type: "json_object" }); + expect(secondBody.response_format).toBeUndefined(); + }); + + it("honors disabled thinking for the legacy account alias without changing inherited requests", async () => { + const account = resolved(); + if (!account.ok) throw new Error("fixture"); + account.context.protocol = "memmy-account"; + account.context.provider = "memmy_account"; + account.context.model = "agent_chat"; + account.context.source = "account"; + account.provider.protocol = "memmy-account"; + account.provider.provider = "memmy_account"; + account.provider.apiBase = "https://account.example/api/agentExternal/v1"; + const defaults = { enable_thinking: true, reasoning_effort: "high", max_tokens: 32000 }; + account.provider.extraBody = { ...defaults }; + let body: Record = {}; + const fetch = vi.fn(async (_url: unknown, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ choices: [{ message: { content: "{}" }, finish_reason: "stop" }] })); + }); + const service = createPluginModelInferenceService({ + resolveModel: async () => account, + fetch: fetch as typeof globalThis.fetch + }); + await service.invoke({ + pluginId: "legal-labor", callId: "account-thinking", conversationId: "v", service: "model-inference", + input: { messages: [{ role: "user", content: "Write one paragraph" }], thinkingMode: "disabled", maxOutputTokens: 1100 } + }); + expect(body).toMatchObject({ max_tokens: 1100, enable_thinking: false }); + for (const key of ["thinking", "reasoning_effort", "reasoning", "max_completion_tokens"]) { + expect(body).not.toHaveProperty(key); + } + await service.invoke({ + pluginId: "literature-review", callId: "account-inherit", conversationId: "v", service: "model-inference", + input: { messages: [{ role: "user", content: "Plan research" }] } + }); + expect(body).toMatchObject(defaults); + expect(account.provider.extraBody).toEqual(defaults); + }); + + it("reports a JSON response cut off at the token ceiling instead of returning half of it", async () => { + const fetch = vi.fn(async () => new Response(JSON.stringify({ + choices: [{ message: { content: "{\"risks\":[{\"item\":\"unfinis" }, finish_reason: "length" }] + }), { status: 200, headers: { "content-type": "application/json" } })); + const service = createPluginModelInferenceService({ + resolveModel: async () => resolved(), + fetch: fetch as typeof globalThis.fetch, + retryBaseDelayMs: 0 + }); + const call = { + pluginId: "legal-labor", callId: "truncated", conversationId: "v", service: "model-inference", + input: { messages: [{ role: "user", content: "Diagnose" }], responseFormat: "json" as const, maxOutputTokens: 512 } + }; + await expect(service.invoke(call)).rejects.toMatchObject({ + code: "model_response_truncated", + message: expect.stringContaining("this request's 512-token output budget (maxOutputTokens)"), + retryable: false + }); + // Retrying cannot help, so the ceiling is reported after a single attempt. + expect(fetch).toHaveBeenCalledTimes(1); + + // A text caller still gets the partial answer, along with the finish reason + // it needs to judge whether the answer is usable. + await expect(service.invoke({ ...call, input: { ...call.input, responseFormat: "text" as const } })) + .resolves.toMatchObject({ content: "{\"risks\":[{\"item\":\"unfinis", finishReason: "length" }); + }); + + it("names the ceiling when reasoning consumes the whole budget, rather than calling it an empty answer", async () => { + const fetch = vi.fn(async () => new Response(JSON.stringify({ + choices: [{ message: { content: "" }, finish_reason: "length" }], + usage: { completion_tokens: 512, completion_tokens_details: { reasoning_tokens: 512 } } + }), { status: 200, headers: { "content-type": "application/json" } })); + const service = createPluginModelInferenceService({ + resolveModel: async () => resolved(), + fetch: fetch as typeof globalThis.fetch, + retryBaseDelayMs: 0 + }); + await expect(service.invoke({ + pluginId: "legal-labor", callId: "reasoned-away", conversationId: "v", service: "model-inference", + input: { messages: [{ role: "user", content: "Diagnose" }], maxOutputTokens: 512 } + })).rejects.toMatchObject({ code: "model_response_truncated", retryable: false, + message: expect.stringContaining("contentCharacters=0; outputTokens=512; reasoningTokens=512; thinkingStrategy=inherit") }); + // Nothing about this is a JSON compatibility problem, so dropping the JSON + // constraint and asking again would only spend the same budget twice. + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("keeps reporting truncation after the JSON compatibility fallback rewrote the format", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: "" }, finish_reason: "stop" }] + }), { status: 200, headers: { "content-type": "application/json" } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: "{\"records\":[{\"item\":\"cut" }, finish_reason: "length" }] + }), { status: 200, headers: { "content-type": "application/json" } })); + const service = createPluginModelInferenceService({ + resolveModel: async () => resolved(), + fetch: fetch as typeof globalThis.fetch, + retryBaseDelayMs: 0 + }); + // The fallback asks again without response_format, but the caller still + // wants JSON: handing it this body would make it report a malformed answer. + await expect(service.invoke({ + pluginId: "legal-labor", callId: "fallback-truncated", conversationId: "v", service: "model-inference", + input: { messages: [{ role: "user", content: "Diagnose" }], responseFormat: "json", maxAttempts: 1 } + })).rejects.toMatchObject({ code: "model_response_truncated" }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("treats a non-JSON gateway body as the transient upstream failure it is", async () => { + const fetch = vi.fn() + // What the account gateway answers, verbatim, when its upstream stalls. + .mockResolvedValueOnce(new Response("stream timeout", { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: "{\"records\":[]}" }, finish_reason: "stop" }] + }), { status: 200, headers: { "content-type": "application/json" } })); + const service = createPluginModelInferenceService({ + resolveModel: async () => resolved(), + fetch: fetch as typeof globalThis.fetch, + retryBaseDelayMs: 0 + }); + await expect(service.invoke({ + pluginId: "legal-labor", callId: "stream-timeout", conversationId: "v", service: "model-inference", + input: { messages: [{ role: "user", content: "Diagnose" }], responseFormat: "json" } + })).resolves.toMatchObject({ content: "{\"records\":[]}" }); + expect(fetch).toHaveBeenCalledTimes(2); + + const exhausted = createPluginModelInferenceService({ + resolveModel: async () => resolved(), + fetch: (async () => new Response("stream timeout", { status: 200 })) as typeof globalThis.fetch, + retryBaseDelayMs: 0 + }); + // The body is quoted back, because "no content" and "a body we could not + // read" send whoever is looking at this to different places. + await expect(exhausted.invoke({ + pluginId: "legal-labor", callId: "stream-timeout-twice", conversationId: "v", service: "model-inference", + input: { messages: [{ role: "user", content: "Diagnose" }] } + })).rejects.toMatchObject({ code: "model_inference_failed", message: expect.stringContaining("stream timeout") }); + }); + + it("retries transient gateway failures with the configured retry policy", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(new Response("bad gateway", { status: 502 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: "recovered after 502" }, finish_reason: "stop" }] + }), { status: 200, headers: { "content-type": "application/json" } })); + const service = createPluginModelInferenceService({ + resolveModel: async () => resolved(), + fetch: fetch as typeof globalThis.fetch, + maxAttempts: 2, + retryBaseDelayMs: 0 + }); + await expect(service.invoke({ + pluginId: "literature-review", callId: "retry-502", conversationId: "conversation-1", service: "model-inference", + input: { messages: [{ role: "user", content: "Continue" }] } + })).resolves.toMatchObject({ content: "recovered after 502" }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("aborts an in-flight model request when the owning plugin run is cancelled", async () => { + const requestStarted = Promise.withResolvers(); + let receivedSignal: AbortSignal | undefined; + const fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + receivedSignal = init?.signal ?? undefined; + requestStarted.resolve(); + return await new Promise((_resolve, reject) => { + receivedSignal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); + }); + const controller = new AbortController(); + const service = createPluginModelInferenceService({ + resolveModel: async () => resolved(), + fetch: fetch as typeof globalThis.fetch, + maxAttempts: 1 + }); + const pending = service.invoke({ + pluginId: "literature-review", callId: "cancel-model", conversationId: "conversation-1", service: "model-inference", + input: { messages: [{ role: "user", content: "Write" }] }, signal: controller.signal + }); + await requestStarted.promise; + controller.abort(); + await expect(pending).rejects.toMatchObject({ code: "plugin_call_cancelled", retryable: false }); + expect(receivedSignal?.aborted).toBe(true); + }); + + it("isolates an opted-out plugin cancellation from concurrent and subsequent inherited model calls", async () => { + const model = resolved(); + if (!model.ok) throw new Error("fixture"); + model.context.model = "qwen3.8-max-0902"; + const defaults = { enable_thinking: true, reasoning_effort: "high", max_tokens: 8192 }; + model.provider.extraBody = structuredClone(defaults); + const started = Promise.withResolvers(); + const bodies: Record[] = []; + const fetch = vi.fn(async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + bodies.push(body); + if (body.messages[0].content === "cancel this call") { + started.resolve(); + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); + } + return new Response(JSON.stringify({ choices: [{ message: { content: "unaffected" }, finish_reason: "stop" }] })); + }); + const service = createPluginModelInferenceService({ resolveModel: async () => model, fetch: fetch as typeof globalThis.fetch }); + const controller = new AbortController(); + const call = { pluginId: "literature-review", callId: "cancel-one", conversationId: "v", service: "model-inference" }; + const pending = service.invoke({ ...call, signal: controller.signal, + input: { messages: [{ role: "user", content: "cancel this call" }], thinkingMode: "disabled" } }); + const cancelled = expect(pending).rejects.toMatchObject({ code: "plugin_call_cancelled", retryable: false }); + await started.promise; + await expect(service.invoke({ ...call, pluginId: "legal-labor", callId: "concurrent-other-plugin", + input: { messages: [{ role: "user", content: "inherited concurrent call" }] } })).resolves.toMatchObject({ content: "unaffected" }); + controller.abort(); + await cancelled; + await expect(service.invoke({ ...call, pluginId: "other-plugin", callId: "after-cancel", + input: { messages: [{ role: "user", content: "inherited subsequent call" }] } })).resolves.toMatchObject({ content: "unaffected" }); + expect(bodies[0]).toMatchObject({ enable_thinking: false }); + expect(bodies[1]).toMatchObject(defaults); + expect(bodies[2]).toMatchObject(defaults); + expect(model.provider.extraBody).toEqual(defaults); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it("honors a bounded per-request timeout requested by a plugin", async () => { + let receivedSignal: AbortSignal | undefined; + const fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + receivedSignal = init?.signal ?? undefined; + return await new Promise((_resolve, reject) => { + receivedSignal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); + }); + const service = createPluginModelInferenceService({ + resolveModel: async () => resolved(), + fetch: fetch as typeof globalThis.fetch, + maxAttempts: 1 + }); + await expect(service.invoke({ + pluginId: "literature-review", callId: "short-timeout", conversationId: "conversation-1", service: "model-inference", + input: { messages: [{ role: "user", content: "Generate keywords" }], timeoutMs: 1_000 } + })).rejects.toMatchObject({ code: "model_inference_timeout", retryable: true }); + expect(receivedSignal?.aborted).toBe(true); + }); + + it("delegates embedding inference to the Memory-owned current model without exposing configuration", async () => { + const embeddingInference = vi.fn(async () => ({ + embeddings: [[1, 0], [0, 1]], + model: { provider: "local", model: "Xenova/all-MiniLM-L6-v2", mode: "local" as const, dimension: 2 } + })); + const service = createPluginModelInferenceService({ + resolveModel: async () => null, + embeddingInference + }); + const result = await service.invoke({ + pluginId: "literature-review", + callId: "call-embedding", + conversationId: "conversation-1", + service: "embedding-inference", + input: { texts: ["section query", "evidence document"], role: "document" } + }); + expect(result).toEqual({ + embeddings: [[1, 0], [0, 1]], + model: { provider: "local", model: "Xenova/all-MiniLM-L6-v2", mode: "local", dimension: 2 } + }); + expect(embeddingInference).toHaveBeenCalledWith( + { texts: ["section query", "evidence document"], role: "document" }, + { signal: undefined } + ); + }); + + it("rejects invalid or unavailable embedding requests", async () => { + const unavailable = createPluginModelInferenceService({ resolveModel: async () => null }); + await expect(unavailable.invoke({ + pluginId: "p", callId: "c", conversationId: "v", service: "embedding-inference", input: { texts: ["query"] } + })).rejects.toMatchObject({ code: "embedding_unavailable", retryable: false }); + + const embeddingInference = vi.fn(); + const available = createPluginModelInferenceService({ resolveModel: async () => null, embeddingInference }); + await expect(available.invoke({ + pluginId: "p", callId: "c", conversationId: "v", service: "embedding-inference", input: { texts: [] } + })).rejects.toBeDefined(); + expect(embeddingInference).not.toHaveBeenCalled(); + }); +}); + +function resolved(): ModelSelectionResolution { + return { + ok: true, + context: { + presetId: "agent-default", provider: "openai", endpointId: "default", protocol: "openai-chat-completions", + model: "current-user-model", source: "byok", ownerAccountId: null, capability: "agent", capabilities: ["agent"] + }, + provider: { + provider: "openai", endpointId: "default", protocol: "openai-chat-completions", + apiBase: "https://models.example/v1", apiKey: "secret-key", extraHeaders: {}, extraBody: {} + } + }; +} diff --git a/App/backend/src/services/tests/plugin-model-policy.test.ts b/App/backend/src/services/tests/plugin-model-policy.test.ts new file mode 100644 index 000000000..86a44f15f --- /dev/null +++ b/App/backend/src/services/tests/plugin-model-policy.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolvePluginModelSelection } from "../plugin-model-policy.js"; + +const ACCOUNT_PRESET = { + ok: true as const, + selection: { provider: "memmy", model: "memmy-agent", endpointId: "cloud" }, + context: { source: "account" as const } +}; +const BYOK_PRESET = { + ok: true as const, + selection: { provider: "openai", model: "gpt-4o", endpointId: "byok" }, + context: { source: "byok" as const } +}; + +function signedIn(userId = "u-1") { + return { authenticated: true as const, profile: { userId } }; +} + +function signedOut() { + return { authenticated: false as const }; +} + +describe("插件模型策略", () => { + it("account-only 插件在账号模式下拿到账号预设", async () => { + const resolveAssignedModel = vi.fn(async () => ACCOUNT_PRESET); + + const resolved = await resolvePluginModelSelection({ + accountOnly: true, + userMode: "account", + account: signedIn() as never, + resolveAssignedModel: resolveAssignedModel as never + }); + + expect(resolved).toBe(ACCOUNT_PRESET); + expect(resolveAssignedModel).toHaveBeenCalledWith({ + mode: "account", + activeAccountId: "u-1", + capability: "agent" + }); + }); + + it("优先解析发起插件调用的对话模型预设", async () => { + const resolveAssignedModel = vi.fn(async () => ACCOUNT_PRESET); + + const resolved = await resolvePluginModelSelection({ + accountOnly: true, + userMode: "account", + account: signedIn() as never, + requestedPreset: "account-deepseek-v4.1-flash", + resolveAssignedModel: resolveAssignedModel as never + }); + + expect(resolved).toBe(ACCOUNT_PRESET); + expect(resolveAssignedModel).toHaveBeenCalledWith({ + mode: "account", + activeAccountId: "u-1", + capability: "agent", + requestedPreset: "account-deepseek-v4.1-flash" + }); + }); + + it("account-only 插件在 BYOK 模式下拿不到模型", async () => { + const resolveAssignedModel = vi.fn(async () => BYOK_PRESET); + + const resolved = await resolvePluginModelSelection({ + accountOnly: true, + userMode: "byok", + account: signedIn() as never, + resolveAssignedModel: resolveAssignedModel as never + }); + + expect(resolved).toBeNull(); + expect(resolveAssignedModel).not.toHaveBeenCalled(); + }); + + it("账号模式下解析出的 BYOK 预设同样被拒", async () => { + // 这是绕过密封的那条路:账号模式允许挂 BYOK 预设,只看 userMode 就会把 + // 密文发到第三方端点,那边解不开,插件也就等于被白嫖走了一次调用。 + const resolveAssignedModel = vi.fn(async () => BYOK_PRESET); + + const resolved = await resolvePluginModelSelection({ + accountOnly: true, + userMode: "account", + account: signedIn() as never, + resolveAssignedModel: resolveAssignedModel as never + }); + + expect(resolved).toBeNull(); + }); + + it("account-only 插件在未登录时拿不到模型", async () => { + const resolveAssignedModel = vi.fn(async () => ACCOUNT_PRESET); + + const resolved = await resolvePluginModelSelection({ + accountOnly: true, + userMode: "account", + account: signedOut() as never, + resolveAssignedModel: resolveAssignedModel as never + }); + + expect(resolved).toBeNull(); + expect(resolveAssignedModel).not.toHaveBeenCalled(); + }); + + it("account-only 插件遇到解析失败时拿不到模型", async () => { + const resolveAssignedModel = vi.fn(async () => ({ ok: false as const, reason: "no_preset" })); + + const resolved = await resolvePluginModelSelection({ + accountOnly: true, + userMode: "account", + account: signedIn() as never, + resolveAssignedModel: resolveAssignedModel as never + }); + + expect(resolved).toBeNull(); + }); + + it("普通插件在 BYOK 模式下照常拿到 BYOK 预设", async () => { + const resolveAssignedModel = vi.fn(async () => BYOK_PRESET); + + const resolved = await resolvePluginModelSelection({ + accountOnly: false, + userMode: "byok", + account: signedIn() as never, + resolveAssignedModel: resolveAssignedModel as never + }); + + expect(resolved).toBe(BYOK_PRESET); + expect(resolveAssignedModel).toHaveBeenCalledWith({ + mode: "byok", + activeAccountId: "u-1", + capability: "agent" + }); + }); + + it("未知用户模式下任何插件都拿不到模型", async () => { + const resolveAssignedModel = vi.fn(async () => ACCOUNT_PRESET); + + const resolved = await resolvePluginModelSelection({ + accountOnly: false, + userMode: "local", + account: signedIn() as never, + resolveAssignedModel: resolveAssignedModel as never + }); + + expect(resolved).toBeNull(); + expect(resolveAssignedModel).not.toHaveBeenCalled(); + }); + + it("宿主没有模型解析能力时返回空而不是抛错", async () => { + const resolved = await resolvePluginModelSelection({ + accountOnly: false, + userMode: "account", + account: signedIn() as never, + resolveAssignedModel: undefined + }); + + expect(resolved).toBeNull(); + }); +}); diff --git a/App/backend/src/services/tests/plugin-network-policy.test.ts b/App/backend/src/services/tests/plugin-network-policy.test.ts new file mode 100644 index 000000000..b5a79d4f6 --- /dev/null +++ b/App/backend/src/services/tests/plugin-network-policy.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_COMMAND_PLUGIN_NETWORK_ALLOWLIST, + resolveCommandPluginNetworkAllowlist +} from "../index.js"; + +describe("command plugin network policy", () => { + it("allows only the exact hosts required by the built-in literature-review providers by default", () => { + expect(resolveCommandPluginNetworkAllowlist({})).toEqual([ + "export.arxiv.org", + "arxiv.org", + "eutils.ncbi.nlm.nih.gov", + "pmc.ncbi.nlm.nih.gov", + "api.openalex.org", + "api.crossref.org" + ]); + expect(DEFAULT_COMMAND_PLUGIN_NETWORK_ALLOWLIST).toContain("api.crossref.org"); + expect(DEFAULT_COMMAND_PLUGIN_NETWORK_ALLOWLIST).not.toContain("*.crossref.org"); + }); + + it("accepts an explicit exact-host deployment override", () => { + expect(resolveCommandPluginNetworkAllowlist({ + MEMMY_COMMAND_PLUGIN_NETWORK_ALLOWLIST: " EXAMPLE.COM,api.example.com " + })).toEqual(["example.com", "api.example.com"]); + }); +}); diff --git a/App/backend/src/services/tests/plugin-rules.test.ts b/App/backend/src/services/tests/plugin-rules.test.ts new file mode 100644 index 000000000..6dede7cd3 --- /dev/null +++ b/App/backend/src/services/tests/plugin-rules.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { PluginRulesSchema } from "@memmy/local-api-contracts"; +import { pluginEligibility, pluginRules } from "../plugin-rules.js"; + +describe("plugin product policy", () => { + it("preserves public manual installation defaults", () => { + expect(PluginRulesSchema.parse({}).autoUpdate).toBe(false); + expect(pluginRules({}).installation).toBe("manual"); + expect(pluginEligibility({}, false, () => false)).toEqual({ visible: true, canInstall: true, canUse: true, reason: null }); + }); + it("hides beta plugins without server-issued grants", () => { + const manifest = { requiredEntitlement: "plugin:legal", rules: PluginRulesSchema.parse({ requiresLogin: true, unavailableVisibility: "hidden" }) }; + expect(pluginEligibility(manifest, false, () => false).visible).toBe(false); + expect(pluginEligibility(manifest, true, () => false).reason).toBe("entitlement_required"); + expect(pluginEligibility(manifest, true, (grant) => grant === "plugin:legal").canUse).toBe(true); + }); + it("enforces requiredEntitlement with and without additional rules", () => { + for (const rules of [undefined, PluginRulesSchema.parse({ installation: "automatic" })]) { + const manifest = { requiredEntitlement: "plugin:legal", ...(rules ? { rules } : {}) }; + expect(pluginEligibility(manifest, true, () => false).canInstall).toBe(false); + expect(pluginEligibility(manifest, true, () => false).reason).toBe("entitlement_required"); + if (!rules) expect(pluginEligibility(manifest, true, () => false).visible).toBe(false); + expect(pluginEligibility(manifest, true, (grant) => grant === "plugin:legal").canUse).toBe(true); + } + }); + it("allows an explicit policy to show an unavailable private plugin", () => { + const manifest = { requiredEntitlement: "plugin:legal", rules: PluginRulesSchema.parse({ unavailableVisibility: "visible" }) }; + expect(pluginEligibility(manifest, true, () => false)).toEqual({ + visible: true, canInstall: false, canUse: false, reason: "entitlement_required" + }); + }); + it("does not turn a model recommendation into an access restriction", () => { + const manifest = { rules: PluginRulesSchema.parse({ recommendsOfficialModel: true }) }; + expect(pluginEligibility(manifest, false, () => false).canUse).toBe(true); + }); +}); diff --git a/App/backend/src/services/tests/plugin-service.test.ts b/App/backend/src/services/tests/plugin-service.test.ts new file mode 100644 index 000000000..dd30774f9 --- /dev/null +++ b/App/backend/src/services/tests/plugin-service.test.ts @@ -0,0 +1,688 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { reconcileEntitledPlugins } from "../plugin-entitlement-reconcile-service.js"; +import { createInMemoryPluginRegistry, type PluginRelease } from "../../adapters/outbound/plugin-registry/index.js"; +import { createAppStateStore, type AppStateStore } from "../../infrastructure/app-state-store/index.js"; +import { + createPluginService, + type PluginArtifactManager, + type PluginRuntimeHost +} from "../plugin-service.js"; +import type { PluginLocalArtifactService } from "../plugin-local-artifact-service.js"; + +let root: string | undefined; +let store: AppStateStore | undefined; + +afterEach(() => { + store?.close(); + store = undefined; + if (root) rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +const release = { + manifest: { + apiVersion: "memmy/v1" as const, + id: "com.example.review", + name: "Review", + version: "1.0.0", + runtime: { adapter: "http" as const, config: { baseUrl: "https://plugin.example" } }, + capabilities: [{ + id: "run", + name: "Run review", + description: "Runs a review", + inputSchema: { type: "object", required: ["topic"], properties: { topic: { type: "string" } } }, + outputSchema: { type: "object" }, + execution: "job" as const + }], + permissions: [ + { type: "network" as const, hosts: ["plugin.example"] }, + { type: "secret" as const, keys: ["api-key"] } + ], + configSchema: { + type: "object", + required: ["database"], + properties: { database: { type: "string" } }, + additionalProperties: false + } + } +}; + +function createContext( + releases: PluginRelease[] = [release], + localArtifactService?: PluginLocalArtifactService, + isAuthenticated?: () => boolean, + catalogPluginIds?: () => readonly string[], + isEntitlementGranted?: (entitlement: string) => boolean, + hasActiveAgentTasks?: (signal: AbortSignal) => Promise +) { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-service-")); + store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + const runtimeHost: PluginRuntimeHost = { + supports: vi.fn(() => true), + activate: vi.fn(async () => undefined), + deactivate: vi.fn(async () => undefined), + async *invoke() { + yield { type: "result" as const, output: { ok: true } }; + }, + cancel: vi.fn(async () => undefined), + respond: vi.fn(async () => undefined) + }; + const artifactManager: PluginArtifactManager = { + install: vi.fn(async (pluginRelease) => ({ + artifactHash: pluginRelease.artifact?.sha256.toLowerCase() ?? null, + rootPath: null + })), + readTextFile: vi.fn(async () => "
renderer
"), + remove: vi.fn(async () => undefined) + }; + const skillManager = { + activate: vi.fn(async () => undefined), + deactivate: vi.fn(async () => undefined) + }; + const registry = createInMemoryPluginRegistry(releases); + return { + registry, + runtimeHost, + artifactManager, + skillManager, + service: createPluginService({ + repository: store.repositories.plugins, + secretStore: store.secretStore, + registry, + runtimeHost, + artifactManager, + skillManager, + localArtifactService, + isAuthenticated, + catalogPluginIds, + isEntitlementGranted, + hasActiveAgentTasks + }) + }; +} + +describe("PluginService", () => { + it("hides a legacy private install after revocation and restores visibility when regranted", async () => { + let granted = true; + const legalRelease: PluginRelease = { manifest: { + ...release.manifest, id: "legal-labor", requiredEntitlement: "plugin:legal-labor", + permissions: [], configSchema: undefined + } }; + const { service, artifactManager } = createContext([legalRelease], undefined, () => true, + () => granted ? ["legal-labor"] : [], () => granted); + await service.install("legal-labor"); + expect(service.list()[0].eligibility?.visible).toBe(true); + + granted = false; + expect(service.list()[0].eligibility).toEqual({ + visible: false, canInstall: false, canUse: false, reason: "entitlement_required" + }); + expect(await service.catalog()).toEqual([]); + await expect(service.enable("legal-labor")).rejects.toThrow(/access denied/); + expect(artifactManager.remove).not.toHaveBeenCalled(); + + granted = true; + expect(service.list()[0].eligibility?.visible).toBe(true); + expect((await service.catalog())[0].manifest.id).toBe("legal-labor"); + expect(artifactManager.install).toHaveBeenCalledTimes(1); + }); + + it("does not expose a private plugin from the public registry without its entitlement", async () => { + const publicRelease: PluginRelease = { manifest: { + ...release.manifest, id: "office", permissions: [], configSchema: undefined + } }; + const legalRelease: PluginRelease = { manifest: { + ...release.manifest, + id: "legal-labor", + name: "用工合规诊断", + permissions: [], + configSchema: undefined, + requiredEntitlement: "plugin:legal-labor", + rules: { distribution: "account", installation: "automatic", requiresLogin: true, + unavailableVisibility: "hidden", autoUpdate: true, recommendsOfficialModel: false } + } }; + const { service } = createContext( + [publicRelease, legalRelease], + undefined, + () => true, + () => [], + () => false + ); + + expect((await service.catalog()).map((entry) => entry.manifest.id)).toEqual(["office"]); + }); + + it("adds entitled private releases to the catalog and keeps them visible after uninstall", async () => { + const publicRelease: PluginRelease = { manifest: { + ...release.manifest, id: "office", permissions: [], configSchema: undefined + } }; + const legalRelease: PluginRelease = { manifest: { + ...release.manifest, + id: "legal-labor", + name: "用工合规诊断", + permissions: [], + configSchema: undefined, + requiredEntitlement: "plugin:legal-labor", + rules: { distribution: "account", installation: "automatic", requiresLogin: true, + unavailableVisibility: "hidden", autoUpdate: true, recommendsOfficialModel: false } + } }; + const { service, registry } = createContext( + [publicRelease, legalRelease], + undefined, + () => true, + () => ["legal-labor"], + (entitlement) => entitlement === "plugin:legal-labor" + ); + vi.spyOn(registry, "list").mockResolvedValue([publicRelease.manifest]); + + expect((await service.catalog()).map((entry) => entry.manifest.id)).toEqual(["office", "legal-labor"]); + await service.install("legal-labor", legalRelease.manifest.version); + await service.approvePermissions("legal-labor", []); + await service.enable("legal-labor"); + expect((await service.catalog()).find((entry) => entry.manifest.id === "legal-labor")?.installed?.state).toBe("active"); + + await service.uninstall("legal-labor"); + const uninstalled = (await service.catalog()).find((entry) => entry.manifest.id === "legal-labor"); + expect(uninstalled?.installed).toBeUndefined(); + expect(uninstalled?.eligibility).toMatchObject({ visible: true, canInstall: true, canUse: true }); + + await service.install("legal-labor", legalRelease.manifest.version); + expect(store!.repositories.plugins.listUserUninstalledIds()).toEqual([]); + }); + + 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("automatically installs after login, drains calls on logout, restores without download, and respects uninstall", async () => { + let authenticated = false; + const literature: PluginRelease = { manifest: { + ...release.manifest, id: "literature-review", permissions: [], configSchema: undefined, + rules: { distribution: "account", installation: "automatic", requiresLogin: true, + unavailableVisibility: "visible", autoUpdate: true, recommendsOfficialModel: true } + } }; + const { service, artifactManager, runtimeHost } = createContext([literature], undefined, () => authenticated); + const reconcile = () => reconcileEntitledPlugins({ plugins: service, entitlements: [], + userUninstalledIds: new Set(store!.repositories.plugins.listUserUninstalledIds()) }); + expect((await service.catalog())[0].eligibility.reason).toBe("login_required"); + await expect(service.install("literature-review")).rejects.toThrow(/access denied/); + await reconcile(); + expect(service.list()).toHaveLength(0); + authenticated = true; + await reconcile(); + expect(service.get("literature-review").state).toBe("active"); + runtimeHost.invoke = async function* () { + yield { type: "progress", current: 1 }; + yield { type: "result", output: { ok: true } }; + }; + const call = service.invoke({ callId: "existing", pluginId: "literature-review", capabilityId: "run", + conversationId: "chat", input: { topic: "memory" } })[Symbol.asyncIterator](); + await call.next(); + authenticated = false; + await reconcile(); + expect(runtimeHost.deactivate).not.toHaveBeenCalled(); + expect(service.list()[0].eligibility?.canUse).toBe(false); + await expect(service.invoke({ callId: "new", pluginId: "literature-review", capabilityId: "run", + conversationId: "chat", input: { topic: "memory" } })[Symbol.asyncIterator]().next()).rejects.toThrow(/access denied/); + expect((await call.next()).value).toMatchObject({ type: "result" }); + await call.next(); + expect(service.get("literature-review").state).toBe("disabled"); + authenticated = true; + await reconcile(); + expect(service.get("literature-review").state).toBe("active"); + expect(artifactManager.install).toHaveBeenCalledTimes(1); + await service.uninstall("literature-review"); + await reconcile(); + expect(service.list()).toHaveLength(0); + await service.install("literature-review"); + expect(store!.repositories.plugins.listUserUninstalledIds()).toEqual([]); + }); + + 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"] } } } + }; + const { service, artifactManager } = createContext([rendererRelease]); + await service.install(rendererRelease.manifest.id); + + await expect(service.readUi(rendererRelease.manifest.id, "renderer")).resolves.toBe("
renderer
"); + expect(artifactManager.readTextFile).toHaveBeenCalledWith( + expect.objectContaining({ id: rendererRelease.manifest.id }), + "ui/index.html", + 512 * 1024 + ); + }); + + it("installs, configures, approves, enables, disables, and uninstalls", async () => { + const { service, runtimeHost, artifactManager, skillManager } = createContext(); + expect((await service.install(release.manifest.id)).state).toBe("pending_approval"); + expect(() => service.configure(release.manifest.id, { config: {} })).toThrow(/Invalid plugin config/); + service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { "api-key": "secret" } + }); + expect((await service.approvePermissions(release.manifest.id, release.manifest.permissions)).state).toBe("installed"); + expect((await service.enable(release.manifest.id)).state).toBe("active"); + expect(runtimeHost.activate).toHaveBeenCalledWith(expect.objectContaining({ id: release.manifest.id }), { + "api-key": "secret" + }); + expect(skillManager.activate).toHaveBeenCalledWith(expect.objectContaining({ id: release.manifest.id })); + expect((await service.disable(release.manifest.id)).state).toBe("disabled"); + expect(skillManager.deactivate).toHaveBeenCalledWith(release.manifest.id); + await service.uninstall(release.manifest.id); + expect(service.list()).toEqual([]); + expect(artifactManager.remove).toHaveBeenCalled(); + }); + + it("rejects undeclared secrets and permissions", async () => { + const { service } = createContext(); + await service.install(release.manifest.id); + expect(() => service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { password: "nope" } + })).toThrow(/not declared/); + await expect(service.approvePermissions(release.manifest.id, [{ + type: "network", + hosts: ["evil.example"] + }])).rejects.toThrow(/did not declare/); + }); + + it.each(["install", "update"] as const)("rejects a changed artifact digest through %s", async (operation) => { + const releases: PluginRelease[] = [{ + ...release, + artifact: { url: "https://plugins.example/review.zip", sha256: "a".repeat(64) } + }]; + const { service } = createContext(releases); + await service.install(release.manifest.id); + releases.push({ + ...release, + artifact: { url: "https://plugins.example/review.zip", sha256: "b".repeat(64) } + }); + + await expect(service[operation](release.manifest.id)).rejects.toThrow(/digest changed/); + }); + + it("rejects a changed manifest for an installed version", async () => { + const changed: PluginRelease = { manifest: { ...release.manifest, name: "Changed" } }; + const releases: PluginRelease[] = [release]; + const { service } = createContext(releases); + await service.install(release.manifest.id); + releases.push(changed); + + await expect(service.update(release.manifest.id)).rejects.toThrow(/manifest changed/); + }); + + it("compares permission scope independently of descriptions and list order", async () => { + const declared = [ + { type: "network" as const, hosts: ["api.example", "cdn.example"], description: "Paper APIs" }, + { type: "secret" as const, keys: ["api-key", "client-id"], description: "Credentials" } + ]; + const pluginRelease: PluginRelease = { + manifest: { ...release.manifest, permissions: declared } + }; + const { service } = createContext([pluginRelease]); + await service.install(release.manifest.id); + + expect(await service.approvePermissions(release.manifest.id, [ + { type: "network", hosts: ["cdn.example", "api.example"] }, + { type: "secret", keys: ["client-id", "api-key"] } + ])).toMatchObject({ state: "installed" }); + }); + + it("marks activation failures without publishing the plugin as active", async () => { + const { service, runtimeHost } = createContext(); + await service.install(release.manifest.id); + service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { "api-key": "secret" } + }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + vi.mocked(runtimeHost.activate).mockRejectedValueOnce(new Error("offline")); + await expect(service.enable(release.manifest.id)).rejects.toThrow(/offline/); + expect(service.get(release.manifest.id)).toMatchObject({ state: "failed", lastError: "offline" }); + }); + + it("marks an active plugin failed when a permission change cannot stop its runtime", async () => { + const { service, runtimeHost } = createContext(); + await service.install(release.manifest.id); + service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { "api-key": "secret" } + }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + vi.mocked(runtimeHost.deactivate).mockRejectedValueOnce(new Error("busy")); + + await expect(service.approvePermissions(release.manifest.id, [])).rejects.toThrow(/busy/); + expect(service.get(release.manifest.id)).toMatchObject({ + state: "failed", + approvedPermissions: release.manifest.permissions, + lastError: "busy" + }); + }); + + it("waits for all calls before resolving/downloading, deduplicates manual updates and pins the target", async () => { + const next = { manifest: { ...release.manifest, version: "2.0.0" } }; + const { service, runtimeHost, artifactManager, registry } = createContext([release, next]); + await service.install(release.manifest.id, "1.0.0"); + service.configure(release.manifest.id, { config: { database: "crossref" }, secrets: { "api-key": "secret" } }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + const releaseCalls = new Map void>(); + runtimeHost.invoke = async function* (call) { + yield { type: "progress", current: 1 }; + await new Promise(resolve => releaseCalls.set(call.callId, resolve)); + yield { type: "result", output: { ok: true } }; + }; + const call = (callId: string) => service.invoke({ + callId, pluginId: release.manifest.id, capabilityId: "run", conversationId: "chat", input: {} + })[Symbol.asyncIterator](); + const one = call("one"), two = call("two"); + await one.next(); + await two.next(); + const finishOne = one.next(), finishTwo = two.next(); + const resolveSpy = vi.spyOn(registry, "resolve"); + vi.mocked(artifactManager.install).mockClear(); + const updated = service.update(release.manifest.id, "2.0.0"); + const duplicate = service.update(release.manifest.id, "2.0.0"); + await expect(service.update(release.manifest.id, "3.0.0")).rejects.toThrow(/another version/); + expect(service.get(release.manifest.id)).toMatchObject({ + version: "1.0.0", activeCallCount: 2, update: { status: "waiting", version: "2.0.0" } + }); + expect(resolveSpy).not.toHaveBeenCalled(); + expect(artifactManager.install).not.toHaveBeenCalled(); + releaseCalls.get("one")!(); + await finishOne; + await one.next(); + expect(artifactManager.install).not.toHaveBeenCalled(); + releaseCalls.get("two")!(); + await finishTwo; + await two.next(); + expect((await updated).version).toBe("2.0.0"); + expect((await duplicate).version).toBe("2.0.0"); + expect(artifactManager.install).toHaveBeenCalledTimes(1); + expect(resolveSpy).toHaveBeenCalledWith(release.manifest.id, "2.0.0"); + expect(service.get(release.manifest.id)).toMatchObject({ state: "active", activeCallCount: 0 }); + expect(service.get(release.manifest.id).update).toBeUndefined(); + }); + + it("blocks new calls during installation and preserves the old active version on download failure", async () => { + const next = { manifest: { ...release.manifest, version: "2.0.0" } }; + const { service, artifactManager } = createContext([release, next]); + await service.install(release.manifest.id, "1.0.0"); + service.configure(release.manifest.id, { config: { database: "crossref" }, secrets: { "api-key": "secret" } }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + let rejectDownload!: (error: Error) => void; + vi.mocked(artifactManager.install).mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectDownload = reject; })); + const update = service.update(release.manifest.id, "2.0.0"); + const failed = expect(update).rejects.toThrow("download failed"); + await vi.waitFor(() => expect(rejectDownload).toBeDefined()); + await expect(service.invoke({ + callId: "new", pluginId: release.manifest.id, capabilityId: "run", conversationId: "chat", input: {} + })[Symbol.asyncIterator]().next()).rejects.toThrow(/being installed/); + rejectDownload(new Error("download failed")); + await failed; + expect(service.get(release.manifest.id)).toMatchObject({ version: "1.0.0", state: "active" }); + expect(service.get(release.manifest.id).update).toBeUndefined(); + }); + + it.each(["return", "error", "shutdown"] as const)("releases a queued update when a call ends via %s", async (ending) => { + const next = { manifest: { ...release.manifest, version: "2.0.0" } }; + const { service, runtimeHost, artifactManager } = createContext([release, next]); + await service.install(release.manifest.id, "1.0.0"); + service.configure(release.manifest.id, { config: { database: "crossref" }, secrets: { "api-key": "secret" } }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + runtimeHost.invoke = async function* () { + yield { type: "progress", current: 1 }; + throw new Error("task failed"); + }; + const task = service.invoke({ callId: "running", pluginId: release.manifest.id, capabilityId: "run", conversationId: "chat", input: {} })[Symbol.asyncIterator](); + await task.next(); + vi.mocked(artifactManager.install).mockClear(); + const update = service.update(release.manifest.id, "2.0.0"); + if (ending === "shutdown") { + const rejected = expect(update).rejects.toThrow(/shutting down/); + await service.shutdown(); + await rejected; + await task.return?.(); + expect(artifactManager.install).not.toHaveBeenCalled(); + } else { + if (ending === "error") await expect(task.next()).rejects.toThrow("task failed"); + else await task.return?.(); + expect((await update).version).toBe("2.0.0"); + expect(artifactManager.install).toHaveBeenCalledOnce(); + } + expect(service.get(release.manifest.id).update).toBeUndefined(); + }); + + it("keeps the old version between sequential calls until the entire Agent task finishes", async () => { + vi.useFakeTimers(); + try { + let agentBusy = true; + const activity = vi.fn(async () => agentBusy); + const next = { manifest: { ...release.manifest, version: "2.0.0" } }; + const { service, artifactManager, registry, runtimeHost } = createContext([release, next], undefined, undefined, undefined, undefined, activity); + await service.install(release.manifest.id, "1.0.0"); + service.configure(release.manifest.id, { config: { database: "crossref" }, secrets: { "api-key": "secret" } }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + const invoke = async (callId: string) => { + for await (const _event of service.invoke({ callId, pluginId: release.manifest.id, capabilityId: "run", conversationId: "chat", input: {} })) { /* drain */ } + }; + await invoke("first-step"); + const resolve = vi.spyOn(registry, "resolve"); + vi.mocked(artifactManager.install).mockClear(); + const update = service.update(release.manifest.id, "2.0.0"); + await vi.advanceTimersByTimeAsync(2_000); + expect(service.get(release.manifest.id)).toMatchObject({ version: "1.0.0", activeCallCount: 0, update: { status: "waiting" } }); + expect(resolve).not.toHaveBeenCalled(); + expect(artifactManager.install).not.toHaveBeenCalled(); + expect(runtimeHost.deactivate).not.toHaveBeenCalled(); + // The same turn can think, wait for user input, then invoke another step. + await invoke("second-step"); + await vi.advanceTimersByTimeAsync(2_000); + expect(artifactManager.install).not.toHaveBeenCalled(); + agentBusy = false; + await vi.advanceTimersByTimeAsync(1_000); + expect((await update).version).toBe("2.0.0"); + expect(resolve).toHaveBeenCalledExactlyOnceWith(release.manifest.id, "2.0.0"); + expect(artifactManager.install).toHaveBeenCalledOnce(); + } finally { vi.useRealTimers(); } + }); + + it("fails closed when the Agent task state cannot be confirmed", async () => { + const next = { manifest: { ...release.manifest, version: "2.0.0" } }; + const { service, artifactManager, registry } = createContext([release, next], undefined, undefined, undefined, undefined, async () => { throw new Error("runtime unavailable"); }); + await service.install(release.manifest.id, "1.0.0"); + vi.mocked(artifactManager.install).mockClear(); + const resolve = vi.spyOn(registry, "resolve"); + await expect(service.update(release.manifest.id, "2.0.0")).rejects.toThrow("runtime unavailable"); + expect(artifactManager.install).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + expect(service.get(release.manifest.id).version).toBe("1.0.0"); + expect(service.get(release.manifest.id).update).toBeUndefined(); + }); + + it("cancels whole-task waiting on shutdown without downloading", async () => { + vi.useFakeTimers(); + try { + const next = { manifest: { ...release.manifest, version: "2.0.0" } }; + const { service, artifactManager } = createContext([release, next], undefined, undefined, undefined, undefined, async () => true); + await service.install(release.manifest.id, "1.0.0"); + vi.mocked(artifactManager.install).mockClear(); + const updated = service.update(release.manifest.id, "2.0.0"); + const rejected = expect(updated).rejects.toThrow(); + await vi.advanceTimersByTimeAsync(0); + await service.shutdown(); + await rejected; + expect(artifactManager.install).not.toHaveBeenCalled(); + expect(service.get(release.manifest.id).update).toBeUndefined(); + } finally { vi.useRealTimers(); } + }); + + it("invokes only active declared capabilities", async () => { + const { service, runtimeHost } = createContext(); + await service.install(release.manifest.id); + service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { "api-key": "secret" } + }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + const events = []; + for await (const event of service.invoke({ + callId: "call-1", + pluginId: release.manifest.id, + capabilityId: "run", + conversationId: "conversation-1", + input: {} + })) events.push(event); + expect(events).toEqual([{ type: "result", output: { ok: true } }]); + expect(runtimeHost.invoke).toBeDefined(); + expect(store!.db.prepare("SELECT outcome, error_code FROM plugin_call_logs WHERE call_id = ?") + .get("call-1")).toEqual({ outcome: "success", error_code: null }); + expect(() => service.configure(release.manifest.id, { + config: { database: "pubmed" } + })).toThrow(/Disable plugin/); + }); + + it("rewrites result paths to the copies published in the conversation workspace", async () => { + const sourcePath = "/plugin-data/review-1/outputs/review.md"; + const publishedPath = "/workspace/outputs/com.example.review/review-1/review.md"; + const localArtifactService: PluginLocalArtifactService = { + host: vi.fn(async (_plugin, artifact) => ({ ...artifact, path: publishedPath })), + open: vi.fn(async () => ({ path: sourcePath, name: "review.md", mediaType: "text/markdown" })), + revokePlugin: vi.fn() + }; + const { service, runtimeHost } = createContext([release], localArtifactService); + runtimeHost.invoke = async function* () { + yield { + type: "artifact", + artifact: { + id: "review", + name: "review.md", + mediaType: "text/markdown", + uri: "file:///plugin-data/review-1/outputs/review.md" + } + }; + yield { type: "result", output: { data: { outputs: [{ path: sourcePath }] } } }; + }; + await service.install(release.manifest.id); + service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { "api-key": "secret" } + }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + + const events = []; + for await (const event of service.invoke({ + callId: "call-1", + pluginId: release.manifest.id, + capabilityId: "run", + conversationId: "websocket:chat-1", + input: { taskId: "review-1" } + })) events.push(event); + + expect(events).toEqual([ + expect.objectContaining({ type: "artifact", artifact: expect.objectContaining({ path: publishedPath }) }), + { type: "result", output: { data: { outputs: [{ path: publishedPath }] } } } + ]); + expect(localArtifactService.host).toHaveBeenCalledWith( + expect.objectContaining({ id: release.manifest.id }), + expect.objectContaining({ id: "review" }), + { conversationId: "websocket:chat-1", taskId: "review-1", callId: "call-1" } + ); + }); + + it("updates an active plugin and reactivates it atomically", async () => { + const nextRelease: PluginRelease = { + manifest: { ...release.manifest, version: "2.0.0" } + }; + const { service, runtimeHost } = createContext([release, nextRelease]); + await service.install(release.manifest.id, "1.0.0"); + service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { "api-key": "secret" } + }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + expect(await service.update(release.manifest.id, "2.0.0")).toMatchObject({ + version: "2.0.0", + state: "active", + config: { database: "crossref" } + }); + expect(runtimeHost.deactivate).toHaveBeenCalledWith(release.manifest.id); + expect(runtimeHost.activate).toHaveBeenCalledTimes(2); + }); + + it("restores the previous active version when an update cannot activate", async () => { + const nextRelease: PluginRelease = { manifest: { ...release.manifest, version: "2.0.0" } }; + const { service, runtimeHost, artifactManager } = createContext([release, nextRelease]); + vi.mocked(artifactManager.install).mockImplementation(async (pluginRelease) => ({ + artifactHash: pluginRelease.manifest.version, + rootPath: `/plugins/${pluginRelease.manifest.version}` + })); + await service.install(release.manifest.id, "1.0.0"); + service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { "api-key": "secret" } + }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + vi.mocked(runtimeHost.activate).mockRejectedValueOnce(new Error("new version failed")); + + await expect(service.update(release.manifest.id, "2.0.0")).rejects.toThrow(/new version failed/); + expect(service.get(release.manifest.id)).toMatchObject({ version: "1.0.0", state: "active" }); + expect(runtimeHost.activate).toHaveBeenCalledTimes(3); + expect(artifactManager.remove).toHaveBeenCalledWith(expect.objectContaining({ + rootPath: "/plugins/2.0.0" + })); + }); + + it("restores and closes persisted active runtimes without changing their state", async () => { + const { service, runtimeHost } = createContext(); + await service.install(release.manifest.id); + service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { "api-key": "secret" } + }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + vi.mocked(runtimeHost.activate).mockClear(); + + await service.restoreActive(); + await service.shutdown(); + + expect(runtimeHost.activate).toHaveBeenCalledOnce(); + expect(runtimeHost.deactivate).toHaveBeenCalledWith(release.manifest.id); + expect(service.get(release.manifest.id).state).toBe("active"); + }); + + it("recovers an installation interrupted while enabling", async () => { + const { service, runtimeHost } = createContext(); + await service.install(release.manifest.id); + service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { "api-key": "secret" } + }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + store!.repositories.plugins.setState(release.manifest.id, "enabling"); + + await service.restoreActive(); + + expect(runtimeHost.activate).toHaveBeenCalledOnce(); + expect(service.get(release.manifest.id).state).toBe("active"); + }); +}); diff --git a/App/backend/src/services/tests/plugin-skill-content-service.test.ts b/App/backend/src/services/tests/plugin-skill-content-service.test.ts new file mode 100644 index 000000000..a7a4468ba --- /dev/null +++ b/App/backend/src/services/tests/plugin-skill-content-service.test.ts @@ -0,0 +1,47 @@ +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPluginSkillContentService, type PluginSkillContentService } from "../plugin-skill-content-service.js"; + +const secret = "PRIVATE-SKILL-SENTINEL"; +const payload = JSON.stringify({ format: "memmy-plugin-skill-v1", pluginId: "literature-review", skillId: "literature-review", pluginVersion: "1.0.0", revision: "r1", + files: { "SKILL.md": secret, "references/evidence-policy.md": "policy" } }); +const sha256 = createHash("sha256").update(payload).digest("hex"); +const instances: PluginSkillContentService[] = []; +afterEach(() => { instances.splice(0).forEach(service => service.clear()); }); +function fixture(body = payload) { + let authorization = "Bearer account-a"; + let active = true; + const fetchFn = vi.fn(async () => new Response(body)); + const service = createPluginSkillContentService({ baseUrl: "https://cloud.test.invalid", authHeaders: () => authorization ? { authorization } : {}, fetch: fetchFn as any, + getPlugin: () => ({ id: "literature-review", version: "1.0.0", state: active ? "active" : "disabled", manifest: { skills: [{ id: "literature-review", cloud: { revision: "r1", sha256 } }] } } as any) }); + instances.push(service); + return { service, fetchFn, login: (token: string) => { authorization = token; }, disable: () => { active = false; } }; +} +describe("private Skill cloud reads", () => { + it("returns metadata only from reference and deduplicates account-bound memory reads", async () => { + const { service, fetchFn, login } = fixture(); + const ref = service.reference("literature-review", "literature-review"); + expect(JSON.stringify(ref)).not.toContain(secret); + const results = await Promise.all([service.resolve(ref), service.resolve(ref)]); + expect(results.every(text => text.includes(secret))).toBe(true); + expect(fetchFn).toHaveBeenCalledTimes(1); + await service.resolve(ref); expect(fetchFn).toHaveBeenCalledTimes(1); + login("Bearer account-b"); await service.resolve(ref); expect(fetchFn).toHaveBeenCalledTimes(2); + login(""); await expect(service.resolve(ref)).rejects.toThrow("access_denied"); + }); + it("rejects digest mismatch, stale releases and disabled plugins", async () => { + const bad = fixture(payload + "tampered"); + await expect(bad.service.resolve(bad.service.reference("literature-review", "literature-review"))).rejects.toThrow(); + const { service, disable } = fixture(); const ref = service.reference("literature-review", "literature-review"); + await expect(service.resolve({ ...ref, pluginVersion: "old" })).rejects.toThrow("version_changed"); + await service.resolve(ref); disable(); await expect(service.resolve(ref)).rejects.toThrow("access_denied"); + }); + it("does not repopulate cache after invalidation during a cloud read", async () => { + const { service, fetchFn } = fixture(); const ref = service.reference("literature-review", "literature-review"); + let complete!: (value: Response) => void; + fetchFn.mockImplementationOnce(() => new Promise(resolve => { complete = resolve; })); + const pending = service.resolve(ref); service.clear(); complete(new Response(payload)); + await expect(pending).rejects.toThrow(); + await service.resolve(ref); expect(fetchFn).toHaveBeenCalledTimes(2); + }); +}); 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/backend/src/tests/literature-review-plugin-e2e.test.ts b/App/backend/src/tests/literature-review-plugin-e2e.test.ts new file mode 100644 index 000000000..ef1361048 --- /dev/null +++ b/App/backend/src/tests/literature-review-plugin-e2e.test.ts @@ -0,0 +1,950 @@ +import { AskQuestionTool, respondToAgentQuestion } from "../../../memmy-agent/src/core/agent-runtime/tools/ask-question.js"; +import { RequestContext } from "../../../memmy-agent/src/core/agent-runtime/tools/context.js"; +import { createPluginSkillContentService } from "../services/plugin-skill-content-service.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createServer, type Server as HttpServer } from "node:http"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdir, readFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import Fastify from "fastify"; +import { buildPluginMcpServer } from "../adapters/inbound/local-api/routes/plugin-mcp.js"; +import { registerPluginRoutes } from "../adapters/inbound/local-api/routes/plugins.js"; +import { createPluginArtifactManager } from "../adapters/outbound/plugin-artifact/index.js"; +import { createHttpPluginRegistry } from "../adapters/outbound/plugin-registry/index.js"; +import { + createCommandPluginAdapter, + createPluginRuntimeHost, + PluginAdapterRegistry +} from "../adapters/outbound/plugin-runtime/index.js"; +import { createPluginSkillManager } from "../adapters/outbound/plugin-skill/index.js"; +import { createAppStateStore, type AppStateStore } from "../infrastructure/app-state-store/index.js"; +import { createPluginService } from "../services/plugin-service.js"; +import { createPluginLocalArtifactService } from "../services/plugin-local-artifact-service.js"; +import { createProgressBus } from "../services/progress-bus.js"; +import { AgentRunSpec, AgentRunner } from "../../../memmy-agent/src/core/agent-runtime/runner.js"; +import { ContextBuilder } from "../../../memmy-agent/src/core/agent-runtime/context.js"; +import { MCPToolWrapper } from "../../../memmy-agent/src/core/agent-runtime/tools/mcp.js"; +import { ToolRegistry } from "../../../memmy-agent/src/core/agent-runtime/tools/registry.js"; +import { LLMResponse, ToolCallRequest } from "../../../memmy-agent/src/providers/base.js"; +import { resolveModelSelection } from "../../../memmy-agent/src/providers/model-catalog.js"; +import YAML from "yaml"; + +const pluginRoot = resolve(process.env.LITERATURE_REVIEW_PLUGIN_ROOT ?? join(process.cwd(), "..", "Literature-Review-Plugin")); +const manifestPath = join(pluginRoot, "plugin.json"); +const integrationAvailable = existsSync(manifestPath); +const liveAgentEnabled = process.env.LITERATURE_REVIEW_LIVE_AGENT === "1"; +let root: string | undefined; +let store: AppStateStore | undefined; +let client: Client | undefined; +let mcpServer: ReturnType | undefined; +let registryServer: HttpServer | undefined; + +afterEach(async () => { + await client?.close(); + await mcpServer?.close(); + if (registryServer) await new Promise((resolveClose) => registryServer!.close(() => resolveClose())); + store?.close(); + client = undefined; + mcpServer = undefined; + registryServer = undefined; + store = undefined; + if (root) rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +describe.skipIf(!integrationAvailable)("installed Literature Review Plugin", () => { + it("installs the real MPP, runs the complete review tool chain, hosts outputs, and unregisters on disable", async () => { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const descriptor = JSON.parse(readFileSync(join(pluginRoot, "release", `${manifest.id}-${manifest.version}.release.json`), "utf8")); + const archivePath = join(pluginRoot, "release", descriptor.artifact.file); + const registry = await serveRelease(manifest, descriptor.artifact.sha256, archivePath); + registryServer = registry.server; + root = mkdtempSync(join(tmpdir(), "memmy-literature-review-package-e2e-")); + const installRoot = join(root, "installed-plugins"); + const dataRoot = join(root, "plugin-data"); + const mediaRoot = join(root, "agent-data", "media"); + const skillsRoot = join(root, "workspace", "skills"); + const trace: Array> = []; + mkdirSync(mediaRoot, { recursive: true }); + const sourcePath = join(mediaRoot, "host-service-evidence.txt"); + writeFileSync(sourcePath, Array.from({ length: 18 }, (_, index) => + `Study ${index + 1} reports that retrieval quality improves evidence grounding in persistent agent memory, while stale context increases conflicts and unsupported answers.` + ).join("\n\n"), "utf8"); + const preloadPath = join(root, "academic-provider-fixture.mjs"); + writeFileSync(preloadPath, providerFetchPreload(), "utf8"); + + store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + const runtimeHost = createPluginRuntimeHost(new PluginAdapterRegistry([ + createCommandPluginAdapter({ + platform: "win32", + isEntitlementGranted: (grant) => grant === "plugin:literature-review", + allowedNetworkHosts: manifest.permissions + .filter((permission: { type: string }) => permission.type === "network") + .flatMap((permission: { hosts: string[] }) => permission.hosts), + fileInputRoots: [mediaRoot], + pluginDataRoot: dataRoot, + hostServices: { + async invoke(call) { + trace.push({ phase: "host-service", service: call.service, callId: call.callId }); + if (call.service === "model-inference") { + const input = call.input as { messages?: Array<{ role?: string; content?: string }> }; + const system = input.messages?.find((message) => message.role === "system")?.content ?? ""; + const request = JSON.parse(input.messages?.at(-1)?.content ?? "{}") as Record; + return { + content: JSON.stringify(modelFixtureResponse(system, request)), + finishReason: "stop", + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + model: { provider: "fixture", model: "fixture-model" } + }; + } + if (call.service === "embedding-inference") { + const texts = (call.input as { texts?: string[] }).texts ?? []; + return { + embeddings: texts.map((text) => [ + text.toLowerCase().includes("retriev") ? 1 : 0.2, + text.toLowerCase().includes("memory") ? 1 : 0.2, + text.toLowerCase().includes("evidence") ? 0.8 : 0.1, + 0.4 + ]), + model: { provider: "fixture", model: "fixture-embedding", mode: "local", dimension: 4 } + }; + } + throw Object.assign(new Error(`Unavailable fixture Host service: ${call.service}`), { code: "host_service_unavailable" }); + } + }, + buildLaunch: async (context, config, networkEnabled) => { + trace.push({ phase: "activate", rootPath: context.rootPath, networkEnabled }); + return { + command: process.execPath, + args: ["--import", pathToFileURL(preloadPath).href, join(context.rootPath!, config.command), ...config.args], + cwd: join(context.rootPath!, config.cwd) + }; + } + }) + ])); + const localArtifactService = createPluginLocalArtifactService({ pluginDataRoot: dataRoot }); + const progressBus = createProgressBus(); + const service = createPluginService({ + isAuthenticated: () => true, + isEntitlementGranted: (grant) => grant === "plugin:literature-review", + repository: store.repositories.plugins, + secretStore: store.secretStore, + registry: createHttpPluginRegistry({ baseUrl: registry.baseUrl }), + runtimeHost, + artifactManager: createPluginArtifactManager({ installRoot }), + skillManager: createPluginSkillManager({ skillsRoot }), + localArtifactService + }); + + const installed = await service.install(manifest.id, manifest.version); + trace.push({ phase: "installed", version: installed.version, state: installed.state }); + expect(installed.state).toBe("pending_approval"); + service.configure(manifest.id, { config: {}, secrets: {} }); + await service.approvePermissions(manifest.id, manifest.permissions); + const active = await service.enable(manifest.id); + trace.push({ phase: "enabled", state: active.state }); + expect(active.state).toBe("active"); + expect(readFileSync(join(skillsRoot, "literature-review", "SKILL.md"), "utf8")).toContain("# Literature Review"); + await expect(service.readUi(manifest.id, "renderer")).resolves.toContain("memmy.plugin.render"); + + const bundleBytes = readFileSync(join(pluginRoot, "release", descriptor.skillBundles["literature-review"].file)); + const skillContent = createPluginSkillContentService({ baseUrl: registry.baseUrl, + getPlugin: id => service.list().find(plugin => plugin.id === id) ?? null, + authHeaders: () => ({ authorization: "Bearer fixture-account" }), fetch: async () => new Response(new Uint8Array(bundleBytes)) }); + const skillRef = skillContent.reference(manifest.id, "literature-review"); + mcpServer = buildPluginMcpServer(service, progressBus, undefined, skillContent); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + client = new Client({ name: "literature-review-package-e2e", version: "1.0.0" }); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + const tools = (await client.listTools()).tools; + const createTool = tools.find((tool) => tool.name.includes("review_create_task")); + const statusTool = tools.find((tool) => tool.name.includes("review_get_status")); + expect(createTool).toBeDefined(); + expect(statusTool).toBeDefined(); + trace.push({ phase: "registered", toolCount: tools.length, createTool: createTool!.name }); + + const created = await client.callTool({ + name: createTool!.name, + arguments: { title: "Host package smoke", topic: "memory-augmented agents" }, + _meta: { "memmy.dev/session-key": "desktop:literature-review-e2e", "memmy.dev/cloud-skills": "1", "memmy.dev/private-skills-ready": JSON.stringify([skillRef]) } + }); + if (created.isError) throw new Error(`Installed plugin create-task call failed: ${JSON.stringify(created.content)}`); + expect(created.isError).not.toBe(true); + const createOutput = created.structuredContent as { taskId?: string; suggestedNextTools?: unknown[] }; + expect(createOutput.taskId).toMatch(/^review-/); + trace.push({ phase: "invoked", capabilityId: "review_create_task", taskId: createOutput.taskId }); + + const status = await client.callTool({ + name: statusTool!.name, + arguments: { taskId: createOutput.taskId }, + _meta: { "memmy.dev/session-key": "desktop:literature-review-e2e", "memmy.dev/cloud-skills": "1", "memmy.dev/private-skills-ready": JSON.stringify([skillRef]) } + }); + expect(status.isError).not.toBe(true); + expect(status.structuredContent).toMatchObject({ taskId: createOutput.taskId, ok: true }); + trace.push({ phase: "persisted", capabilityId: "review_get_status", taskId: createOutput.taskId }); + + const cancellableRunId = "interaction-cancel-target"; + const cancellable = service.invoke({ + callId: cancellableRunId, + pluginId: manifest.id, + capabilityId: "review_request_interaction", + conversationId: "desktop:literature-review-e2e", + input: { taskId: createOutput.taskId, type: "source-import" } + })[Symbol.asyncIterator](); + expect((await cancellable.next()).value).toMatchObject({ type: "interaction" }); + let cancelOutput: Record | undefined; + for await (const event of service.invoke({ + callId: "cancel-control-call", + pluginId: manifest.id, + capabilityId: "review_cancel", + conversationId: "desktop:literature-review-e2e", + input: { taskId: createOutput.taskId, scope: "run", runId: cancellableRunId, reason: "E2E cancellation check" } + })) { + if (event.type === "result") cancelOutput = event.output as Record; + } + expect(cancelOutput).toMatchObject({ ok: true, data: { scope: "run", taskCancelled: false, cancelledRun: { runId: cancellableRunId, status: "cancelled" } } }); + expect((await cancellable.next()).value).toMatchObject({ type: "error", code: "plugin_cancelled", retryable: false }); + let cancellationStatus: Record | undefined; + for await (const event of service.invoke({ + callId: "status-after-cancel-result", + pluginId: manifest.id, + capabilityId: "review_get_status", + conversationId: "desktop:literature-review-e2e", + input: { taskId: createOutput.taskId } + })) { + if (event.type === "result") cancellationStatus = event.output as Record; + } + expect(cancellationStatus).toMatchObject({ ok: true, data: { cancelled: false } }); + expect(((cancellationStatus?.data as { runs?: Array<{ runId: string; status: string }> } | undefined)?.runs ?? [])).toContainEqual(expect.objectContaining({ runId: cancellableRunId, status: "cancelled" })); + trace.push({ phase: "run-cancellation-verified", runId: cancellableRunId, taskCancelled: false }); + + const call = async (capabilityId: string, args: Record) => { + const tool = tools.find((candidate) => candidate.name.includes(capabilityId)); + if (!tool) throw new Error(`Agent tool was not registered: ${capabilityId}`); + const result = await client!.callTool({ + name: tool.name, + arguments: args, + _meta: { "memmy.dev/session-key": "desktop:literature-review-e2e", "memmy.dev/cloud-skills": "1", "memmy.dev/private-skills-ready": JSON.stringify([skillRef]) } + }); + if (result.isError) throw new Error(`${capabilityId} failed: ${JSON.stringify(result.content)}`); + trace.push({ phase: "invoked", capabilityId }); + return result.structuredContent as Record; + }; + + const interact = async ( + type: "source-import" | "keywords" | "outline" | "paper-selection", + responseFor: (request: Record) => Record + ) => { + const callId = `interaction-${type}`; + let response: Record | undefined; + let output: Record | undefined; + for await (const event of service.invoke({ + callId, + pluginId: manifest.id, + capabilityId: "review_request_interaction", + conversationId: "desktop:literature-review-e2e", + input: { taskId: createOutput.taskId, type } + })) { + if (event.type === "interaction") { + const request = event.request as unknown as Record; + response = responseFor(request); + trace.push({ + phase: "interaction-requested", + cardType: type, + envelopeType: event.request.type, + interactionId: event.request.interactionId + }); + await service.respond(manifest.id, callId, event.request.interactionId, response); + trace.push({ phase: "interaction-submitted", cardType: type, interactionId: event.request.interactionId }); + } + if (event.type === "result") output = event.output as Record; + } + if (!response || !output) throw new Error(`Interaction ${type} did not complete through the Host response channel.`); + expect(output).toMatchObject({ ok: true, data: { response } }); + return { response, output }; + }; + + const customResponse = (request: Record, values: Record) => { + const payload = request.payload as Record; + const baseArtifact = payload.baseArtifact as Record | undefined; + return { + action: "submit", + ...(typeof baseArtifact?.contentHash === "string" ? { baseArtifactHash: baseArtifact.contentHash } : {}), + values + }; + }; + + const confirmedSpec = { + title: "Hosted literature review artifact", + topic: "How retrieval quality affects grounded long-term agent memory", + researchQuestions: ["How does retrieval quality affect grounded agent memory?"], + onlineSearch: true, + outputLanguage: "en", + citationStyle: "apa", + targetLength: 700, + renderMode: "neurips-2024", + outputFormats: ["pdf", "tex", "docx"], + dateRange: { from: 2022, to: 2026 } + }; + const scopeQuestions: Array> = []; + const scopeTool = (onQuestion?: () => void) => { + const tool = new AskQuestionTool({ sendCallback: async message => { + const card = message.metadata.agentUi.questionCard; + scopeQuestions.push(card); + trace.push({ phase: "native-scope-question", context: card.context }); + expect(card.context).toMatchObject({ workflow: "literature-review" }); + expect(card.questions.map((question: { id: string }) => question.id)).toEqual(["reference-range"]); + onQuestion?.(); + expect(respondToAgentQuestion({ chatId: message.chatId, response: { + requestId: card.requestId, + answers: card.questions.map((question: { id: string }) => ({ questionId: question.id, selectedOptionIds: [], otherText: "2022–2026" })) + } })).toEqual({ ok: true }); + } }); + tool.setContext(new RequestContext({ channel: "websocket", chatId: "literature-review-e2e", sessionKey: "desktop:literature-review-e2e" })); + return tool; + }; + const scopeSuggestion = (createOutput.suggestedNextTools as Array<{ toolName: string; args: Record }>).find(item => item.toolName === "ask_question"); + expect(scopeSuggestion).toBeDefined(); + const scopeAnswer = JSON.parse(await scopeTool().execute(scopeSuggestion!.args)); + expect(scopeAnswer.status).toBe("answered"); + await call("review_update_spec", { taskId: createOutput.taskId, spec: confirmedSpec, questionRequestId: scopeAnswer.requestId }); + const importInteraction = await interact("source-import", () => ({ + files: [{ path: sourcePath, name: "host-service-evidence.txt" }] + })); + const importSuggestion = (importInteraction.output.suggestedNextTools as Array<{ toolName: string; args: Record }>)[0]; + expect(importSuggestion).toMatchObject({ toolName: "review_import_sources" }); + await call("review_import_sources", importSuggestion.args); + await call("review_parse_sources", { taskId: createOutput.taskId }); + const interruptedRunId = "restart-interrupted-keywords"; + const interruptedAt = new Date(Date.now() - 60_000).toISOString(); + writeFileSync(join(dataRoot, manifest.id, "tasks", createOutput.taskId!, "runs", `${interruptedRunId}.json`), `${JSON.stringify({ + runId: interruptedRunId, + taskId: createOutput.taskId, + toolName: "review_generate_keywords", + status: "running", + startedAt: interruptedAt, + updatedAt: interruptedAt, + heartbeatAt: interruptedAt, + ownerPid: 987654321, + input: { taskId: createOutput.taskId }, + inputHash: "sha256:e2e-fixture" + }, null, 2)}\n`, "utf8"); + const restartStatus = await call("review_get_status", { taskId: createOutput.taskId }); + expect(restartStatus, JSON.stringify(restartStatus, null, 2)).toMatchObject({ ok: true }); + expect(((restartStatus.data as { recoverableRuns?: Array<{ runId: string; status: string }> } | undefined)?.recoverableRuns ?? []), JSON.stringify(restartStatus, null, 2)).toContainEqual(expect.objectContaining({ runId: interruptedRunId, status: "interrupted" })); + const resumedKeywords = await call("review_resume", { taskId: createOutput.taskId, runId: interruptedRunId }); + expect(resumedKeywords).toMatchObject({ ok: true, data: { resumedFromRunId: interruptedRunId, resumedToolName: "review_generate_keywords" } }); + trace.push({ phase: "job-recovery-verified", interruptedRunId, resumed: true }); + const generatedKeywords = await call("review_generate_keywords", { taskId: createOutput.taskId }); + const keywordInteraction = await interact("keywords", (request) => customResponse(request, { + keywords: (generatedKeywords.data as { keywords: unknown[] }).keywords + })); + await call("review_update_keywords", { + taskId: createOutput.taskId, + keywords: (keywordInteraction.response.values as { keywords: unknown[] }).keywords + }); + const searched = await call("review_search_papers", { taskId: createOutput.taskId, providers: ["arxiv"], researchDomain: "general", domainRationale: "Agent memory and retrieval are computer science topics", limit: 5 }); + const papers = (searched.data as { candidates: Array<{ id: string; authors: string[]; year?: number }> }).candidates; + expect(papers).toHaveLength(1); + expect(papers[0]).toMatchObject({ authors: ["Ada Lovelace"], year: 2024 }); + const paperId = papers[0]!.id; + const paperInteraction = await interact("paper-selection", (request) => customResponse(request, { + includedPaperIds: [paperId], excludedPaperIds: [] + })); + await call("review_update_paper_selection", { + taskId: createOutput.taskId, + ...(paperInteraction.response.values as { includedPaperIds: string[]; excludedPaperIds: string[] }) + }); + await call("review_resolve_metadata", { taskId: createOutput.taskId }); + const fetched = await call("review_fetch_fulltexts", { taskId: createOutput.taskId }); + expect(fetched.data).toMatchObject({ counts: { downloaded: 1 } }); + const readings = await call("review_build_readings", { taskId: createOutput.taskId }); + const readingData = readings.data as { counts: { evidenceUnits: number } }; + expect(readingData).not.toHaveProperty("evidenceUnits"); + if (!readingData.counts.evidenceUnits) { + const debugStatus = await call("review_get_status", { taskId: createOutput.taskId }); + const values = (debugStatus.data as { values?: Record }).values; + throw new Error(`Downloaded PDF produced no evidence: ${JSON.stringify({ data: readingData, warnings: readings.warnings, diagnostics: values?.downloadParseDiagnostics, documents: values?.parsedDocuments })}`); + } + const outline = await call("review_generate_outline", { taskId: createOutput.taskId }); + expect(outline.data).toMatchObject({ generation: { mode: "host-model" } }); + const outlineInteraction = await interact("outline", (request) => customResponse(request, { + outline: (outline.data as { outline: unknown[] }).outline + })); + await call("review_update_outline", { + taskId: createOutput.taskId, + outline: (outlineInteraction.response.values as { outline: unknown[] }).outline + }); + expect(trace.filter((item) => item.phase === "interaction-submitted").map((item) => item.cardType)).toEqual([ + "source-import", "keywords", "paper-selection", "outline" + ]); + const mapped = await call("review_map_evidence", { taskId: createOutput.taskId }); + const packs = (mapped.data as { sections: Array<{ rankingMode: string }> }).sections; + expect(mapped.data).not.toHaveProperty("evidencePacks"); + expect(packs.length).toBeGreaterThan(0); + expect(packs.every((pack) => pack.rankingMode === "embedding+lexical")).toBe(true); + expect(trace.some((item) => item.phase === "host-service" && item.service === "model-inference")).toBe(true); + expect(trace.some((item) => item.phase === "host-service" && item.service === "embedding-inference")).toBe(true); + expect(existsSync(join(dataRoot, manifest.id, "tasks", createOutput.taskId!, "task.json"))).toBe(true); + trace.push({ phase: "host-boundaries-verified", modelInference: true, embeddingInference: true, taskData: true, networkPolicy: true }); + + let generatedSections = await call("review_generate_sections", { taskId: createOutput.taskId, batchSize: 2 }); + let sectionResumes = 0; + while ((generatedSections.data as { generation: { pendingOutlineNodeIds: string[] } }).generation.pendingOutlineNodeIds.length) { + if (++sectionResumes > 4) throw new Error(`Section generation did not converge: ${JSON.stringify(generatedSections.data)}`); + const next = (generatedSections.suggestedNextTools as Array<{ toolName: string; args: Record }>).find(item => item.toolName === "review_generate_sections"); + expect(next).toBeDefined(); + generatedSections = await call(next!.toolName, next!.args); + } + expect(generatedSections.data, JSON.stringify(generatedSections.data)).toMatchObject({ generation: { mode: "host-model", pendingOutlineNodeIds: [], fallbackOutlineNodeIds: [] } }); + const refined = await call("review_refine", { + taskId: createOutput.taskId, + instruction: "Improve transitions without changing evidence or citations.", + continuityScope: "adjacent", + batchSize: 2 + }); + expect(refined.data).toMatchObject({ refinement: { validationPassed: true, citationsChanged: false } }); + await call("review_plan_figures", { taskId: createOutput.taskId }); + await call("review_build_tables", { taskId: createOutput.taskId }); + const bibliography = await call("review_build_bibliography", { taskId: createOutput.taskId }); + expect((bibliography.data as { records: unknown[] }).records).toHaveLength(1); + const audit = await call("review_audit", { taskId: createOutput.taskId }); + expect(audit.data, JSON.stringify(audit.data, null, 2)).toMatchObject({ audit: { passed: true } }); + const abstractSuggestion = (audit.suggestedNextTools as Array<{ toolName: string; args?: Record }>).find((item) => item.toolName === "review_generate_abstract"); + expect(abstractSuggestion?.args?.expectedArtifactHash).toMatch(/^sha256:/); + const generatedAbstract = await call("review_generate_abstract", { + taskId: createOutput.taskId, + expectedArtifactHash: abstractSuggestion!.args!.expectedArtifactHash + }); + expect(generatedAbstract.data).toMatchObject({ abstract: { language: "en" } }); + const renderPreflight = await call("review_check_render_environment", { taskId: createOutput.taskId }); + expect(renderPreflight.data).toMatchObject({ + environment: process.env.LITERATURE_REVIEW_REQUIRE_XELATEX === "1" ? { available: true, runtimeDependency: "texlive" } : { runtimeDependency: "texlive" } + }); + + const renderEvents = []; + for await (const event of service.invoke({ + callId: "artifact-render-call", + pluginId: manifest.id, + capabilityId: "review_render", + conversationId: "desktop:literature-review-e2e", + input: { taskId: createOutput.taskId, formats: ["md", "bib", "pdf", "tex", "docx"] } + })) renderEvents.push(event); + trace.push({ phase: "invoked", capabilityId: "review_render" }); + const hostedArtifacts = renderEvents.flatMap((event) => event.type === "artifact" ? [event.artifact] : []); + expect(hostedArtifacts.map((artifact) => artifact.name).sort()).toEqual(["neurips_2024.sty", "references.bib", "review.docx", "review.md", "review.pdf", "review.tex"]); + expect(hostedArtifacts.every((artifact) => artifact.uri.startsWith(`/api/v1/plugins/${manifest.id}/artifacts/`) && artifact.downloadUri?.endsWith("/download"))).toBe(true); + if (process.env.LITERATURE_REVIEW_REQUIRE_XELATEX === "1") { + const renderedTask = JSON.parse(readFileSync(join(dataRoot, manifest.id, "tasks", createOutput.taskId!, "task.json"), "utf8")); + const pdfOutput = renderedTask.values.outputs.find((output: { name: string }) => output.name === "review.pdf"); + expect(pdfOutput?.generation, JSON.stringify(pdfOutput?.generation, null, 2)).toMatchObject({ renderer: "xelatex", fallback: false, compile: { ok: true, passes: 2 } }); + } + + const api = Fastify(); + registerPluginRoutes(api, { plugins: service, progressBus, authenticateRuntimeToken: async () => undefined }); + await api.ready(); + try { + for (const artifact of hostedArtifacts) { + const preview = await api.inject({ method: "GET", url: artifact.uri }); + const download = await api.inject({ method: "GET", url: artifact.downloadUri! }); + expect(preview.statusCode).toBe(200); + expect(preview.headers["content-disposition"]).toContain("inline"); + expect(preview.headers["content-security-policy"]).toContain("sandbox"); + expect(preview.headers["x-content-type-options"]).toBe("nosniff"); + expect(download.statusCode).toBe(200); + expect(download.headers["content-disposition"]).toContain("attachment"); + expect(download.rawPayload.byteLength).toBeGreaterThan(0); + if (artifact.name === "review.md") expect(preview.body).toContain("Hosted literature review artifact"); + if (artifact.name === "references.bib") { + expect(preview.body).toContain("@article{lovelace2024grounded"); + expect(preview.body).toContain("eprint = {2401.01234}"); + } + if (artifact.name === "review.pdf") expect(preview.rawPayload.subarray(0, 4).toString("ascii")).toBe("%PDF"); + if (artifact.name === "review.tex") { + expect(preview.body).toContain("\\documentclass"); + expect(preview.body).toContain("\\usepackage[preprint]{neurips_2024}"); + expect(preview.body).toContain("\\cite{"); + } + if (artifact.name === "neurips_2024.sty") expect(preview.body).toContain("\\ProvidesPackage{neurips_2024}"); + if (artifact.name === "review.docx") { + expect(preview.headers["content-type"]).toContain("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + expect(preview.rawPayload.subarray(0, 2).toString("ascii")).toBe("PK"); + } + } + } finally { + await api.close(); + } + trace.push({ phase: "artifact-api-verified", formats: hostedArtifacts.map((artifact) => artifact.name), preview: true, download: true }); + + const originalToolsByCapability = new Map(); + for (const capability of manifest.capabilities.map((item: { id: string }) => item.id)) { + const tool = tools.find((candidate) => candidate.name.includes(capability)); + if (!tool) throw new Error(`Agent integration capability was not registered: ${capability}`); + originalToolsByCapability.set(capability, tool); + } + const agentTools = new ToolRegistry(); + const agentToolNames = new Map(); + const agentToolCalls: Array<{ name: string; args: Record }> = []; + const mcpSession = { + callTool: async (name: string, args: Record, timeout: number, meta?: Record, signal?: AbortSignal | null) => { + agentToolCalls.push({ name, args: structuredClone(args) }); + return client!.callTool( + { name, arguments: args, ...(meta ? { _meta: meta } : {}) }, + undefined, + { timeout: timeout * 1_000, ...(signal ? { signal } : {}) } + ); + } + }; + const skillLoader = tools.find(tool => tool.name === "read_plugin_skill"); + expect(skillLoader).toBeDefined(); + agentTools.register(new MCPToolWrapper(mcpSession, "plugins", skillLoader!, 60)); + for (const [capability, tool] of originalToolsByCapability) { + const wrapper = new MCPToolWrapper(mcpSession, "plugins", tool, 60); + agentTools.register(wrapper); + agentToolNames.set(capability, wrapper.name); + } + + const agentSpecBeforeChat = { + ...confirmedSpec, + title: "Agent-orchestrated review", + outputLanguage: "en" + }; + const pendingChat: Array> = []; + const interactionResponses: Array> = []; + const agentCardTypes: string[] = []; + agentTools.register(scopeTool(() => pendingChat.push({ + role: "user", content: "把输出语言改为中文,并且只纳入2022年以来的论文。", + client_request_id: "chat-during-native-scope-question", webui_queue_steer_origin: true + }))); + agentToolNames.set("ask_question", "ask_question"); + const unsubscribe = progressBus.on("plugin.capability_event", (event) => { + if (event.capabilityId === "review_create_task" && event.event.type === "result") { + const output = event.event.output as { taskId?: unknown }; + if (typeof output.taskId === "string") agentTaskId = output.taskId; + } + if (event.capabilityId !== "review_request_interaction" || event.event.type !== "interaction") return; + const request = event.event.request; + const payload = request.payload as Record; + const cardType = String(payload.cardType ?? ""); + if (!cardType) return; + agentCardTypes.push(cardType); + const baseArtifact = payload.baseArtifact as Record | undefined; + const data = payload.data as Record | undefined; + const paperCardIds = cardType === "paper-selection" + ? [data?.includedPaperIds, data?.recommendedPaperIds] + .find((value): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string") && value.length > 0) + ?? (Array.isArray(data?.papers) && typeof (data.papers[0] as { id?: unknown })?.id === "string" + ? [(data.papers[0] as { id: string }).id] + : []) + : []; + const response = cardType === "source-import" + ? { files: [{ path: sourcePath, name: "host-service-evidence.txt" }] } + : { + action: "submit", + ...(typeof baseArtifact?.contentHash === "string" ? { baseArtifactHash: baseArtifact.contentHash } : {}), + values: cardType === "keywords" ? { keywords: data?.keywords ?? [] } + : cardType === "outline" ? { outline: data?.outline ?? [] } + : cardType === "paper-selection" ? { + includedPaperIds: paperCardIds, + excludedPaperIds: [] + } + : {} + }; + interactionResponses.push(service.respond(manifest.id, event.callId, request.interactionId, response)); + }); + + let agentModelStep = 0; + let agentTaskId = ""; + const agentModelMessages: Array>> = []; + const scriptedAgentProvider = { + generation: { maxTokens: 512 }, + getDefaultModel: () => "fixture-agent-model", + chatWithRetry: async ({ messages }: { messages: Array> }) => { + agentModelMessages.push(structuredClone(messages)); + const tool = (capability: string, args: Record) => new LLMResponse({ + content: null, + finishReason: "tool_calls", + toolCalls: [new ToolCallRequest({ + id: `agent-call-${agentModelStep}`, + name: agentToolNames.get(capability)!, + arguments: args + })] + }); + const step = agentModelStep++; + if (step === 0) return tool("review_create_task", { + title: "Agent-orchestrated review", + topic: "retrieval quality and grounded agent memory" + }); + const latestToolOutput = [...messages].reverse().find((message) => message.role === "tool")?.content; + if (typeof latestToolOutput === "string" && latestToolOutput.startsWith("Error")) { + throw new Error(`Scripted Agent step ${step} received a tool validation failure: ${latestToolOutput.slice(0, 2000)}`); + } + const parsed = typeof latestToolOutput === "string" ? JSON.parse(latestToolOutput) as Record : {}; + if (!agentTaskId && typeof parsed.taskId === "string") agentTaskId = parsed.taskId; + if (parsed.ok === false) throw new Error(`Scripted Agent received a failed plugin operation: ${JSON.stringify(parsed)}`); + const data = (parsed.data ?? {}) as Record; + const response = (data.response ?? {}) as Record; + const values = (response.values ?? {}) as Record; + if (step === 1) { + const suggestion = (parsed.suggestedNextTools as Array<{ toolName: string; args: Record }>).find(item => item.toolName === "ask_question"); + if (!suggestion) throw new Error("Task creation did not request native research scope confirmation"); + return tool(suggestion.toolName, suggestion.args); + } + if (step === 2) { + const sawSteering = messages.some((message) => message.role === "user" && String(message.content).includes("2022")); + if (!sawSteering) throw new Error("The Agent did not receive Chat steering submitted while the card was open."); + return tool("review_update_spec", { + taskId: agentTaskId, + questionRequestId: parsed.requestId, + spec: { + ...agentSpecBeforeChat, + outputLanguage: "zh-CN", + dateRange: { from: 2022 } + } + }); + } + if (step === 3) return tool("review_request_interaction", { taskId: agentTaskId, type: "source-import" }); + if (step === 4) { + const suggestion = (parsed.suggestedNextTools as Array<{ args: Record }>)[0]; + return tool("review_import_sources", suggestion.args); + } + if (step === 5) return tool("review_parse_sources", { taskId: agentTaskId }); + if (step === 6) return tool("review_generate_keywords", { taskId: agentTaskId }); + if (step === 7) return tool("review_request_interaction", { taskId: agentTaskId, type: "keywords" }); + if (step === 8) return tool("review_update_keywords", { taskId: agentTaskId, keywords: values.keywords }); + if (step === 9) return tool("review_search_papers", { taskId: agentTaskId, providers: ["arxiv"], researchDomain: "general", domainRationale: "Agent memory and retrieval are computer science topics", limit: 5 }); + if (step === 10) return tool("review_request_interaction", { taskId: agentTaskId, type: "paper-selection" }); + if (step === 11) return tool("review_update_paper_selection", { + taskId: agentTaskId, + includedPaperIds: values.includedPaperIds, + excludedPaperIds: values.excludedPaperIds + }); + if (step === 12) return tool("review_resolve_metadata", { taskId: agentTaskId }); + if (step === 13) return tool("review_fetch_fulltexts", { taskId: agentTaskId }); + if (step === 14) return tool("review_build_readings", { taskId: agentTaskId }); + if (step === 15) return tool("review_generate_outline", { taskId: agentTaskId }); + if (step === 16) return tool("review_request_interaction", { taskId: agentTaskId, type: "outline" }); + if (step === 17) return tool("review_update_outline", { taskId: agentTaskId, outline: values.outline }); + if (step === 18) return tool("review_map_evidence", { taskId: agentTaskId }); + if (step === 19) return tool("review_generate_sections", { taskId: agentTaskId, batchSize: 2 }); + if (step === 20 && (data.generation as { pendingOutlineNodeIds?: string[] } | undefined)?.pendingOutlineNodeIds?.length) { + const suggestion = (parsed.suggestedNextTools as Array<{ toolName: string; args: Record }>).find(item => item.toolName === "review_generate_sections"); + if (!suggestion || agentModelStep > 40) throw new Error("Missing bounded section-resume suggestion"); + agentModelStep--; + return tool(suggestion.toolName, suggestion.args); + } + if (step === 20) return tool("review_refine", { + taskId: agentTaskId, + instruction: "Improve transitions without changing evidence or citations.", + continuityScope: "adjacent", + batchSize: 2 + }); + if (step === 21) return tool("review_plan_figures", { taskId: agentTaskId }); + if (step === 22) return tool("review_build_tables", { taskId: agentTaskId }); + if (step === 23) return tool("review_build_bibliography", { taskId: agentTaskId }); + if (step === 24) return tool("review_audit", { taskId: agentTaskId }); + if (step === 25) { + const suggestion = (parsed.suggestedNextTools as Array<{ toolName: string; args: Record }>).find((item) => item.toolName === "review_generate_abstract"); + return tool("review_generate_abstract", { taskId: agentTaskId, expectedArtifactHash: suggestion?.args.expectedArtifactHash }); + } + if (step === 26) return tool("review_render", { taskId: agentTaskId, formats: ["md", "bib", "pdf", "tex", "docx"] }); + if (step === 27) return tool("review_get_status", { taskId: agentTaskId }); + return new LLMResponse({ content: "已根据卡片和最新聊天指令完成综述并生成交付物。", finishReason: "stop" }); + } + }; + const liveConfigPath = resolve(process.env.MEMMY_CONFIG ?? join(homedir(), ".memmy", "config.yaml")); + const liveConfig = liveAgentEnabled && existsSync(liveConfigPath) + ? YAML.parse(readFileSync(liveConfigPath, "utf8")) as { app?: { userId?: string }; modelAssignments?: { account?: { agent?: { default?: string } } } } + : undefined; + const liveMode = liveConfig?.modelAssignments?.account?.agent?.default ? "account" : "byok"; + const liveSelection = liveAgentEnabled ? resolveModelSelection({ + configPath: liveConfigPath, + mode: liveMode, + activeAccountId: liveMode === "account" ? liveConfig?.app?.userId ?? null : null, + capability: "agent" + }) : null; + if (liveAgentEnabled && !liveSelection) throw new Error("The current Memmy user model is unavailable for the live Agent E2E."); + const agentProvider = liveSelection?.snapshot.provider ?? scriptedAgentProvider; + const agentContext = new ContextBuilder({ workspace: join(root, "workspace"), fileMemoryEnabled: false }); + const agentSystemPrompt = agentContext.buildSystemPrompt(["literature-review"]); + expect(agentSystemPrompt).toContain("### Skill: literature-review"); + expect(agentSystemPrompt).toContain("mcp_plugins_read_plugin_skill"); + expect(agentSystemPrompt).not.toContain("## New Full Review"); + let agentResult; + try { + agentResult = await new AgentRunner(agentProvider as never).run(new AgentRunSpec({ + messages: [ + { role: "system", content: agentSystemPrompt }, + { role: "user", content: "$literature-review 请创建一份关于检索质量与智能体长期记忆的综述。请使用 arXiv 检索,首次 review_search_papers 请明确使用 query=\"retrieval quality AND agent memory\"、providers=[\"arxiv\"]、limit=5。请立即通过插件依次发出所有必需卡片让我确认,无需在发卡片前再次用聊天征求许可;收到每张卡片结果后继续完成证据映射、正文、图表、参考文献、审计、摘要和全部交付物。上传的 host-service-evidence.txt 只用于验证文件导入和解析,不是具备书目信息的论文,不要把它加入写作语料、EvidenceUnit 或参考文献;论文选择必须采用卡片返回的 arXiv includedPaperIds。本评测允许保留 partial 或 gap 覆盖诊断,请不要为覆盖缺口重复检索、重复修改大纲、重复映射或重复生成正文,使用当前有全文 EvidenceUnit 的范围继续。正文生成后必须调用 review_refine 完成章节连贯性改写;随后必须分别调用 review_plan_figures 和 review_build_tables,即使没有合格图表也要让工具产出可审计的空结果;最后构建参考文献、审计、生成摘要并渲染。中途不要仅汇报下一步或询问是否继续。" } + ], + privateSkillRefs: [skillRef], + provider: agentProvider as never, + tools: agentTools, + model: liveSelection?.model ?? "fixture-agent-model", + maxIterations: liveAgentEnabled ? 60 : 40, + injectionCallback: ({ limit = 3 } = {}) => pendingChat.splice(0, limit) + })); + await Promise.all(interactionResponses); + } finally { + unsubscribe(); + } + if (liveAgentEnabled) { + const debugPath = resolve(process.env.LITERATURE_REVIEW_LIVE_AGENT_REPORT ?? join(process.cwd(), "artifacts", "literature-review-live-agent-report.json")); + mkdirSync(dirname(debugPath), { recursive: true }); + writeFileSync(debugPath, `${JSON.stringify({ + generatedAt: new Date().toISOString(), + finalContent: agentResult.finalContent, + toolsUsed: agentResult.toolsUsed, + toolCalls: agentToolCalls, + cards: agentCardTypes, + taskId: agentTaskId, + decisionModel: liveSelection ? { provider: liveSelection.provider, model: liveSelection.model } : null + }, null, 2)}\n`, "utf8"); + } + expect(agentResult.finalContent.length).toBeGreaterThan(0); + const privateDocuments = JSON.parse(bundleBytes.toString("utf8")).files as Record; + for (const document of Object.values(privateDocuments)) expect(JSON.stringify(agentResult.messages)).not.toContain(document); + skillContent.clear(); + expect(agentResult.hadInjections).toBe(true); + expect(scopeQuestions).toHaveLength(2); + expect(agentResult.toolsUsed).toContain("ask_question"); + const firstSeenCardTypes = agentCardTypes.filter((cardType, index) => agentCardTypes.indexOf(cardType) === index); + expect(firstSeenCardTypes, JSON.stringify({ + finalContent: agentResult.finalContent, + cards: agentCardTypes, + toolsUsed: agentResult.toolsUsed + }, null, 2)).toEqual(["source-import", "keywords", "paper-selection", "outline"]); + for (const capability of [ + "review_create_task", "review_update_spec", "review_import_sources", "review_parse_sources", "review_generate_keywords", + "review_update_keywords", "review_search_papers", "review_update_paper_selection", "review_resolve_metadata", + "review_fetch_fulltexts", "review_build_readings", "review_generate_outline", "review_update_outline", "review_map_evidence", + "review_generate_sections", "review_refine", "review_plan_figures", "review_build_tables", "review_build_bibliography", + "review_audit", "review_generate_abstract", "review_render" + ]) expect(agentResult.toolsUsed, JSON.stringify({ missing: capability, finalContent: agentResult.finalContent, toolsUsed: agentResult.toolsUsed }, null, 2)).toContain(agentToolNames.get(capability)); + expect(agentResult.toolsUsed.filter((name) => name === agentToolNames.get("review_request_interaction")).length).toBe(4); + const agentTask = JSON.parse(readFileSync(join(dataRoot, manifest.id, "tasks", agentTaskId, "task.json"), "utf8")); + expect(agentTask.spec).toMatchObject({ outputLanguage: "zh-CN", dateRange: { from: 2022 } }); + expect(agentTask.values.sectionGeneration).toMatchObject({ mode: "host-model", pendingOutlineNodeIds: [], fallbackOutlineNodeIds: [] }); + expect(agentTask.values.sectionRefinement).toMatchObject({ mode: "host-model", validationPassed: true }); + expect(agentTask.values.sections.every((section: { markdown: string }) => /[\u4e00-\u9fff]/u.test(section.markdown))).toBe(true); + expect(agentTask.values.outputs.map((output: { name: string }) => output.name).sort()).toEqual([ + "neurips_2024.sty", "references.bib", "review.docx", "review.md", "review.pdf", "review.tex" + ]); + const deliveryDir = process.env.LITERATURE_REVIEW_LIVE_DELIVERY_DIR; + if (liveAgentEnabled && deliveryDir) { + mkdirSync(deliveryDir, { recursive: true }); + for (const output of agentTask.values.outputs as Array<{ name: string; path: string }>) { + copyFileSync(output.path, join(deliveryDir, output.name)); + } + writeFileSync(join(deliveryDir, "delivery.json"), `${JSON.stringify({ + generatedAt: new Date().toISOString(), + taskId: agentTaskId, + decisionModel: liveSelection ? { provider: liveSelection.provider, model: liveSelection.model } : null, + files: agentTask.values.outputs.map((output: { name: string }) => output.name) + }, null, 2)}\n`, "utf8"); + } + if (!liveAgentEnabled) expect(agentModelMessages.at(-1)?.some((message) => message.role === "user" && String(message.content).includes("2022"))).toBe(true); + trace.push({ + phase: "agent-orchestration-verified", + skillLoaded: true, + toolsUsed: agentResult.toolsUsed, + cards: agentCardTypes, + chatSteeringApplied: true, + decisionModel: liveSelection ? { provider: liveSelection.provider, model: liveSelection.model } : { provider: "fixture", model: "fixture-agent-model" }, + taskId: agentTaskId + }); + + await service.disable(manifest.id); + expect((await client.listTools()).tools.map(tool => tool.name)).toEqual(["read_plugin_skill"]); + const disabledSkill = await client.callTool({ name: "read_plugin_skill", arguments: { pluginId: manifest.id, skillId: "literature-review" }, _meta: { "memmy.dev/cloud-skills": "1" } }); + expect(disabledSkill.isError).toBe(true); + expect(existsSync(join(skillsRoot, "literature-review"))).toBe(false); + trace.push({ phase: "disabled", tools: 1, pluginCapabilities: 0, skillRegistered: false }); + await writeReport(trace, { + plugin: { + id: manifest.id, + version: manifest.version, + capabilityCount: manifest.capabilities.length, + skillIds: manifest.skills.map((skill: { id: string }) => skill.id), + uiSlots: Object.keys(manifest.ui ?? {}) + }, + artifact: { + path: archivePath, + sha256: descriptor.artifact.sha256, + bytes: descriptor.artifact.bytes, + files: descriptor.artifact.files + }, + toolCount: tools.length, + taskId: createOutput.taskId + }); + }, liveAgentEnabled ? 900_000 : 120_000); +}); + +function modelFixtureResponse(system: string, request: Record): Record { + const evidence = (request.evidence ?? []) as Array<{ evidenceId: string; citeKey: string }>; + if (request.task === "plan-review-paragraphs") return { + paragraphs: Array.from({ length: Number(request.paragraphCount) }, (_, index) => ({ + point: `Compare retrieval relevance and evidence provenance, argument ${index + 1}`, + evidenceIds: evidence.slice(0, 2).map(unit => unit.evidenceId) + })) + }; + if (request.task === "write-review-paragraph") return { + paragraphId: request.paragraphId, + markdown: request.language === "zh-CN" + ? String(request.paragraphId).endsWith("-1") + ? `所给研究表明,检索相关性有助于提高智能体对持久记忆中证据的可靠复用,减少缺乏支持的回答 [${evidence[0].citeKey}]。` + : `明确的证据来源也有助于减少陈旧上下文引起的冲突,但有限的基准范围约束了结论的推广 [${evidence[0].citeKey}]。` + : String(request.paragraphId).endsWith("-1") + ? `The supplied study associates retrieval relevance with more reliable reuse of persistent memory and fewer unsupported answers [${evidence[0].citeKey}].` + : `Explicit provenance also helps reduce stale-context conflicts, while the limited benchmark scope constrains generalization beyond the evaluated tasks [${evidence[0].citeKey}].` + }; + if (request.task === "check-review-paragraph-continuity") return { passed: true, repairs: [] }; + if (system.includes("design evidence-oriented literature-review outlines")) { + return { outline: [ + { title: "Scope and concepts", guidance: "Define the review scope and core concepts.", children: [] }, + { title: "Evidence synthesis", guidance: "Synthesize retrieval quality and grounding evidence.", children: [ + { title: "Retrieval quality", guidance: "Compare findings about retrieval quality, conflicts, and grounded answers." } + ] } + ] }; + } + if (system.includes("write evidence-grounded literature-review sections") || system.includes("improve continuity in evidence-grounded literature-review sections")) { + const sections = (request.sections ?? request.targetSections ?? []) as Array<{ + outlineNodeId: string; + evidence?: Array<{ evidenceId: string; citeKey: string }>; + evidenceIds?: string[]; + citeKeys?: string[]; + }>; + return { sections: sections.map((section) => { + const evidenceIds = section.evidence?.map((item) => item.evidenceId) ?? section.evidenceIds ?? []; + const citeKeys = section.evidence?.map((item) => item.citeKey) ?? section.citeKeys ?? []; + const focus = request.language === "zh-CN" + ? section.outlineNodeId.includes("2-1") + ? "所给研究将更高的检索相关性与更少的不受支持回答以及更清晰的证据来源联系起来" + : "本综述在可追溯的证据框架下综合检索质量、持久记忆和有依据的智能体行为" + : section.outlineNodeId.includes("2-1") + ? "Across the reviewed evaluations, higher retrieval relevance is associated with fewer unsupported answers and clearer evidence provenance" + : "The review scope connects retrieval quality, persistent memory, and grounded behavior under a shared evidence-tracing framework"; + return { + outlineNodeId: section.outlineNodeId, + markdown: `${focus} ${citeKeys.map((key) => `[${key}]`).join(" ")}.`, + evidenceIds + }; + }) }; + } + if (system.includes("create compact evidence tables")) { + const rows = (request.rows ?? []) as Array<{ paperId: string; evidence?: Array<{ evidenceId: string }> }>; + return { rows: rows.map((row) => ({ + paperId: row.paperId, + method: { text: "Controlled evaluation", evidenceIds: row.evidence?.slice(0, 1).map((item) => item.evidenceId) ?? [] }, + finding: { text: "Reliable retrieval improves grounding", evidenceIds: row.evidence?.slice(0, 1).map((item) => item.evidenceId) ?? [] }, + limitation: { text: "Limited benchmark scope", evidenceIds: row.evidence?.slice(0, 1).map((item) => item.evidenceId) ?? [] } + })) }; + } + if (system.includes("write a journal-style abstract")) { + const sections = (request.sections ?? []) as Array<{ sectionId: string; evidenceIds?: string[] }>; + return { + text: request.language === "zh-CN" + ? "本文综述检索质量如何影响具备长期记忆能力的智能体。研究基于已确认的全文证据,综合比较信息相关性、证据来源、记忆更新以及过期内容处理等主题。现有结果表明,更可靠的检索通常能够提高存储信息的复用质量,减少长任务中的无依据回答,并使生成结论与原始证据保持可追溯联系。同时,仅有表面相关性的记忆仍可能已经过时,或与较新的观察产生冲突,因此检索、来源记录和更新机制需要作为统一且可审计的过程进行设计。当前证据覆盖的任务类型和评测范围仍然有限,尚不足以证明单一检索策略能够稳定迁移到所有领域。未来研究应在更长交互周期、更广任务集合以及信息持续变化的环境中验证这些机制。" + : "This review examines how retrieval quality affects grounded behavior in language agents with persistent memory. It synthesizes the supplied full-text evidence around retrieval relevance, evidence provenance, update behavior, and the handling of stale information. The reviewed findings consistently associate more reliable retrieval with better reuse of stored information and fewer unsupported responses during extended tasks. They also indicate that provenance must remain visible when evidence is selected for generation, because apparently relevant memories may be outdated or conflict with newer observations. The available corpus nevertheless reflects a limited range of evaluations and does not establish that one retrieval strategy transfers uniformly across domains. Overall, the evidence supports treating retrieval, provenance, and memory updating as one auditable process rather than independent components. Future work should evaluate these mechanisms over longer interactions, broader task families, and controlled changes in the underlying information environment.", + sourceSectionIds: sections.map((section) => section.sectionId), + sourceEvidenceIds: [...new Set(sections.flatMap((section) => section.evidenceIds ?? []))] + }; + } + throw new Error(`Unexpected model fixture operation: ${system.slice(0, 80)}`); +} + +function providerFetchPreload(): string { + const feed = ` + + 1 + + https://arxiv.org/abs/2401.01234v1 + 2024-01-03T00:00:00Z2024-01-02T00:00:00Z + Grounded Retrieval for Persistent Agent Memory + This study reports that retrieval quality improves evidence grounding in persistent agent memory while explicit provenance reduces unsupported answers and stale-memory conflicts. + Ada Lovelace + + +`; + const pdf = minimalTextPdf("The study reports that retrieval quality improves evidence grounding in persistent agent memory. Explicit provenance reduces unsupported answers and stale context conflicts. A controlled evaluation compares retrieval relevance across extended tasks. The results indicate more reliable reuse of stored evidence, while the limited benchmark domain constrains generalization. ".repeat(5)); + return `const originalFetch = globalThis.fetch; +const feed = ${JSON.stringify(feed)}; +const pdf = Buffer.from(${JSON.stringify(pdf.toString("base64"))}, "base64"); +globalThis.fetch = async (input, init) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + if (url.hostname === "export.arxiv.org") return new Response(feed, { status: 200, headers: { "content-type": "application/atom+xml" } }); + if (url.hostname === "arxiv.org" && url.pathname.startsWith("/pdf/")) return new Response(pdf, { status: 200, headers: { "content-type": "application/pdf" } }); + return originalFetch(input, init); +};\n`; +} + +function minimalTextPdf(text: string): Buffer { + const lines = text.match(/.{1,78}(?:\s|$)/gu) ?? [text]; + const content = `BT /F1 9 Tf 45 760 Td 11 TL ${lines.map((line, index) => { + const escaped = line.trim().replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); + return `${index ? "T* " : ""}(${escaped}) Tj`; + }).join(" ")} ET`; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream` + ]; + let body = "%PDF-1.4\n"; + const offsets = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(body)); + body += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + const xref = Buffer.byteLength(body); + body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n `).join("\n")}\n`; + body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.from(body, "utf8"); +} + +async function serveRelease(manifest: Record, sha256: string, archivePath: string) { + const archive = await readFile(archivePath); + const server = createServer((request, response) => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + const baseUrl = `http://127.0.0.1:${port}`; + if (request.url?.startsWith(`/api/v1/plugins/${encodeURIComponent(String(manifest.id))}`)) { + const body = Buffer.from(JSON.stringify({ manifest, artifact: { url: `${baseUrl}/artifact.zip`, sha256 } })); + response.writeHead(200, { "content-type": "application/json", "content-length": body.byteLength }); + response.end(body); + return; + } + if (request.url === "/artifact.zip") { + response.writeHead(200, { "content-type": "application/zip", "content-length": archive.byteLength }); + response.end(archive); + return; + } + response.writeHead(404).end(); + }); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", resolveListen); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Local plugin registry did not bind a TCP port"); + return { server, baseUrl: `http://127.0.0.1:${address.port}` }; +} + +async function writeReport(trace: Array>, details: Record) { + const reportPath = process.env.LITERATURE_REVIEW_E2E_REPORT; + if (!reportPath) return; + await mkdir(dirname(resolve(reportPath)), { recursive: true }); + writeFileSync(reportPath, `${JSON.stringify({ + generatedAt: new Date().toISOString(), + result: "passed", + scenario: "real-mpp-install-full-review-render-disable", + details, + trace + }, null, 2)}\n`); +} diff --git a/App/backend/src/tests/literature-review-plugin-live-model.test.ts b/App/backend/src/tests/literature-review-plugin-live-model.test.ts new file mode 100644 index 000000000..7cccd3ce5 --- /dev/null +++ b/App/backend/src/tests/literature-review-plugin-live-model.test.ts @@ -0,0 +1,190 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; +import { createMemmyConfigWriter } from "../infrastructure/memmy-config/index.js"; +import { createPluginModelInferenceService } from "../services/plugin-model-inference-service.js"; + +const pluginRoot = resolve(process.env.LITERATURE_REVIEW_PLUGIN_ROOT ?? join(process.cwd(), "..", "Literature-Review-Plugin")); +const configPath = resolve(process.env.MEMMY_CONFIG ?? join(homedir(), ".memmy", "config.yaml")); +const liveEnabled = process.env.LITERATURE_REVIEW_LIVE_MODEL === "1" + && existsSync(configPath) + && existsSync(join(pluginRoot, "dist/src/algorithms/model-outline.js")); + +describe.skipIf(!liveEnabled)("Literature Review Plugin with the current Memmy user model", () => { + it("generates an outline, resumes section batches, and performs a bounded continuity rewrite", async () => { + const writer = createMemmyConfigWriter({ configPath }); + const safeConfigContext = YAML.parse(readFileSync(configPath, "utf8")) as { app?: { userId?: string }; modelAssignments?: { account?: { agent?: { default?: string } }; byok?: { agent?: { default?: string } } } }; + const preferredMode = safeConfigContext.modelAssignments?.account?.agent?.default ? "account" : "byok"; + const activeAccountId = preferredMode === "account" ? safeConfigContext.app?.userId ?? null : null; + const resolution = await writer.resolveAssignedModel?.({ mode: preferredMode, activeAccountId, capability: "agent" }); + if (!resolution?.ok) throw new Error(`Current Memmy agent model could not be resolved: ${resolution?.reason ?? "unknown"}`); + const mode = preferredMode; + + const service = createPluginModelInferenceService({ resolveModel: async () => resolution, timeoutMs: 180_000 }); + const calls: Array> = []; + const modelClient = async (input: Record) => { + const started = Date.now(); + try { + const output = await service.invoke({ + pluginId: "literature-review", + callId: `live-model-${calls.length + 1}`, + conversationId: "literature-review-live-model", + service: "model-inference", + input, + deadline: new Date(Date.now() + 180_000).toISOString() + }) as { content: string; finishReason: string; usage: Record; model: { provider: string; model: string } }; + calls.push({ + operation: classifyOperation(input), + durationMs: Date.now() - started, + inputCharacters: inputCharacters(input), + outputCharacters: output.content.length, + finishReason: output.finishReason, + usage: output.usage, + model: output.model + }); + return output; + } catch (error) { + calls.push({ + operation: classifyOperation(input), + durationMs: Date.now() - started, + inputCharacters: inputCharacters(input), + failed: true, + code: error instanceof Error && "code" in error ? (error as Error & { code?: string }).code : undefined, + message: error instanceof Error ? error.message : String(error) + }); + throw error; + } + }; + + const outlineModule = await import(pathToFileURL(join(pluginRoot, "dist/src/algorithms/model-outline.js")).href) as { + generateOutlineWithModel(client: typeof modelClient, spec: Record, keywords: unknown[], readings: unknown[]): Promise<{ outline: Array> }>; + }; + const writingModule = await import(pathToFileURL(join(pluginRoot, "dist/src/algorithms/model-writing.js")).href) as { + generateSectionsInBatches(client: typeof modelClient, spec: Record, outline: unknown[], drafts: unknown[], evidence: unknown[], options: Record): Promise<{ sections: Array>; batches: Array> }>; + ensureParagraphBlocks(section: Record, evidence: unknown[]): Record; + refineSectionsWithModel(client: typeof modelClient, spec: Record, outline: unknown[], sections: unknown[], evidence: unknown[], targetIds: string[], batchSize: number, options: Record): Promise<{ sections: Array>; batches: Array>; changedIds: string[]; validationPassed: boolean; citationsChanged: boolean }>; + }; + + const spec = { + taskId: "live-model-check", + title: "Persistent memory in language-model agents", + topic: "How retrieval and update policies affect grounded long-horizon agent memory", + researchQuestions: [ + "How does retrieval quality affect grounded responses?", + "How do update policies reduce stale-memory conflicts?" + ], + outputLanguage: "en", + citationStyle: "apa", + onlineSearch: false, + outputFormats: ["pdf"] + }; + const keywords = [ + { id: "kw-1", term: "agent memory", normalizedTerm: "agent memory", aliases: [], weight: 10, selected: true, source: "spec" }, + { id: "kw-2", term: "retrieval quality", normalizedTerm: "retrieval quality", aliases: [], weight: 9, selected: true, source: "spec" }, + { id: "kw-3", term: "memory update", normalizedTerm: "memory update", aliases: [], weight: 8, selected: true, source: "spec" } + ]; + const readings = [ + { id: "reading-1", paperId: "paper-1", summary: "A controlled benchmark links retrieval precision to grounded answers.", chunkIds: ["chunk-1"], evidenceIds: ["evidence-1"], evidenceLevel: "full-text", writingPermission: "factual-writing", claims: ["Higher retrieval precision reduces unsupported answers."], methods: ["controlled benchmark"], results: ["Unsupported answers decreased with retrieval precision."], limitations: ["One benchmark domain."], confidence: 0.9, qualityFlags: [] }, + { id: "reading-2", paperId: "paper-2", summary: "A longitudinal study evaluates versioned memory replacement.", chunkIds: ["chunk-2"], evidenceIds: ["evidence-2"], evidenceLevel: "full-text", writingPermission: "factual-writing", claims: ["Versioned replacement reduces stale-memory conflicts."], methods: ["longitudinal evaluation"], results: ["Contradictions decreased after versioned replacement."], limitations: ["Limited task families."], confidence: 0.88, qualityFlags: [] } + ]; + const liveOutline = await outlineModule.generateOutlineWithModel(modelClient, spec, keywords, readings); + + const writingOutline = [ + { id: "outline-1", title: "Evidence synthesis", guidance: "Compare retrieval and update mechanisms.", order: 1, evidenceIds: [], children: [ + { id: "outline-1-1", title: "Retrieval quality", guidance: "Synthesize evidence about retrieval precision and grounded answers.", order: 1, evidenceIds: ["evidence-1"], children: [] }, + { id: "outline-1-2", title: "Memory updates", guidance: "Synthesize evidence about stale conflicts and versioned replacement.", order: 2, evidenceIds: ["evidence-2"], children: [] } + ] } + ]; + const evidence = [ + evidenceUnit("evidence-1", "paper-1", "Chen2024", "Higher retrieval precision reduced unsupported answers in a controlled benchmark."), + evidenceUnit("evidence-2", "paper-2", "Rao2025", "Versioned replacement reduced stale-memory contradictions in a longitudinal evaluation.") + ]; + const drafts = [ + sectionDraft("section-1", "outline-1-1", "evidence-1", "Chen2024"), + sectionDraft("section-2", "outline-1-2", "evidence-2", "Rao2025") + ]; + + const firstBatch = await writingModule.generateSectionsInBatches(modelClient, spec, writingOutline, drafts.slice(0, 1), evidence, { batchSize: 1, attempt: 1 }); + const resumedBatch = await writingModule.generateSectionsInBatches(modelClient, spec, writingOutline, drafts.slice(1), evidence, { batchSize: 1, attempt: 2, existingSections: firstBatch.sections }); + const sections = [...firstBatch.sections, ...resumedBatch.sections]; + const hydrated = sections.map((section) => writingModule.ensureParagraphBlocks(section, evidence)); + const firstParagraph = (hydrated[0]?.paragraphs as Array<{ id: string }> | undefined)?.[0]; + if (!firstParagraph) throw new Error("Live model generation did not produce a target paragraph"); + const refinement = await writingModule.refineSectionsWithModel(modelClient, spec, writingOutline, hydrated, evidence, [], 1, { + target: { type: "paragraph", id: firstParagraph.id }, + instruction: "Improve the transition and clarity without changing any factual claim or citation.", + continuityScope: "adjacent" + }); + + const report = { + generatedAt: new Date().toISOString(), + result: "completed", + currentModel: { provider: resolution.context.provider, model: resolution.context.model, mode }, + outline: { topLevelSections: liveOutline.outline.length, schemaValid: liveOutline.outline.length >= 2 }, + sectionGeneration: { + firstRun: firstBatch.batches.map(batchSummary), + resumedRun: resumedBatch.batches.map(batchSummary), + citationsPreserved: sections.every((section) => (section.citeKeys as string[]).every((key) => String(section.markdown).includes(`[${key}]`))) + }, + refinement: { + batches: refinement.batches.map(batchSummary), + validationPassed: refinement.validationPassed, + citationsChanged: refinement.citationsChanged, + changedIds: refinement.changedIds + }, + calls, + totalDurationMs: calls.reduce((sum, call) => sum + Number(call.durationMs ?? 0), 0), + totalTokens: calls.reduce((sum, call) => sum + Number((call.usage as Record | undefined)?.totalTokens ?? 0), 0) + }; + writeReport(report); + + expect(liveOutline.outline.length).toBeGreaterThanOrEqual(2); + expect(firstBatch.batches.every((batch) => batch.status === "completed")).toBe(true); + expect(resumedBatch.batches.every((batch) => batch.status === "completed")).toBe(true); + expect(report.sectionGeneration.citationsPreserved).toBe(true); + expect(refinement.validationPassed).toBe(true); + expect(refinement.citationsChanged).toBe(false); + expect(calls.length).toBeGreaterThanOrEqual(5); + }, 600_000); +}); + +function evidenceUnit(id: string, paperId: string, citeKey: string, statement: string) { + return { + id, paperId, locator: { chunkId: `${id}-chunk`, pageFrom: 1, pageTo: 1, paragraphFrom: 1, paragraphTo: 1 }, + summary: statement, claims: [statement], statements: [{ type: "result", text: statement, sentenceIndex: 1, confidence: 0.9 }], + supportingTextHash: `sha256:${id}`, citeKey, evidenceLevel: "full-text", confidence: 0.9, + quality: { score: 0.9, sourceTextQuality: 0.9, locatorCompleteness: 1, statementCoverage: 1, flags: [] }, + conflict: { status: "none", withEvidenceIds: [] } + }; +} + +function sectionDraft(id: string, outlineNodeId: string, evidenceId: string, citeKey: string) { + return { id, outlineNodeId, markdown: `Evidence is available [${citeKey}].`, paragraphs: [], evidenceIds: [evidenceId], citeKeys: [citeKey], status: "drafted" }; +} + +function classifyOperation(input: Record): string { + const messages = Array.isArray(input.messages) ? input.messages as Array<{ content?: unknown }> : []; + const system = String(messages[0]?.content ?? ""); + if (system.includes("design evidence-oriented")) return "outline"; + if (system.includes("write evidence-grounded")) return "section-generation"; + if (system.includes("rewrite exactly one paragraph")) return "paragraph-rewrite"; + if (system.includes("check local paragraph continuity")) return "continuity-check"; + return "unknown"; +} + +function inputCharacters(input: Record): number { + return (Array.isArray(input.messages) ? input.messages : []).reduce((sum: number, raw) => sum + String((raw as { content?: unknown }).content ?? "").length, 0); +} + +function batchSummary(batch: Record) { + return { status: batch.status, attempt: batch.attempt, outlineNodeIds: batch.outlineNodeIds, issues: batch.issues, error: batch.error }; +} + +function writeReport(report: Record): void { + const output = resolve(process.env.LITERATURE_REVIEW_LIVE_MODEL_REPORT ?? join(process.cwd(), "artifacts", "literature-review-live-model-report.json")); + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`, "utf8"); +} diff --git a/App/backend/src/tests/office-plugin-e2e.test.ts b/App/backend/src/tests/office-plugin-e2e.test.ts new file mode 100644 index 000000000..cefae9c75 --- /dev/null +++ b/App/backend/src/tests/office-plugin-e2e.test.ts @@ -0,0 +1,311 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import Fastify from "fastify"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import JSZip from "jszip"; +import { PluginManifestSchema, type PluginArtifactRef } from "@memmy/local-api-contracts"; +import { createPluginArtifactManager } from "../adapters/outbound/plugin-artifact/index.js"; +import { createHttpPluginRegistry } from "../adapters/outbound/plugin-registry/index.js"; +import { createCommandPluginAdapter, createPluginRuntimeHost, PluginAdapterRegistry } from "../adapters/outbound/plugin-runtime/index.js"; +import { createPluginSkillManager } from "../adapters/outbound/plugin-skill/index.js"; +import { createPluginService, type PluginService } from "../services/plugin-service.js"; +import { createPluginLocalArtifactService } from "../services/plugin-local-artifact-service.js"; +import { createProgressBus } from "../services/progress-bus.js"; +import { createAppStateStore, type AppStateStore } from "../infrastructure/app-state-store/index.js"; +import { buildPluginMcpServer } from "../adapters/inbound/local-api/routes/plugin-mcp.js"; +import { registerPluginRoutes } from "../adapters/inbound/local-api/routes/plugins.js"; +import { AgentRunSpec, AgentRunner } from "../../../memmy-agent/src/core/agent-runtime/runner.js"; +import { ContextBuilder } from "../../../memmy-agent/src/core/agent-runtime/context.js"; +import { MCPToolWrapper } from "../../../memmy-agent/src/core/agent-runtime/tools/mcp.js"; +import { ToolRegistry } from "../../../memmy-agent/src/core/agent-runtime/tools/registry.js"; +import { LLMResponse, ToolCallRequest } from "../../../memmy-agent/src/providers/base.js"; +import { resolveModelSelection } from "../../../memmy-agent/src/providers/model-catalog.js"; +import YAML from "yaml"; +import { createOfficeRuntimeInstaller } from "../adapters/outbound/plugin-runtime/office-installer.js"; +import { serveRegistry } from "../../../../plugins/office/scripts/serve.mjs"; + +const repo = resolve(import.meta.dirname, "../../../.."); +const pluginRoot = join(repo, "plugins/office"); +const releaseRoot = join(pluginRoot, "release"); +const sha = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); +const cleanup: Array<() => unknown> = []; +// A verified renderer contains tens of thousands of files; Windows ACL cleanup +// and antivirus scanning take longer than Vitest's default ten-second hook. +afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await dispose(); }, 120000); + +describe("Office MPP end to end", () => { + it("publishes immutable registry releases and rejects tampered artifacts", async () => { + const root = mkdtempSync(join(tmpdir(), "office-publish-")); cleanup.push(() => rmSync(root, { recursive: true, force: true })); + const manifest = PluginManifestSchema.parse(JSON.parse(readFileSync(join(pluginRoot, "plugin.json"), "utf8"))); + const descriptor = join(releaseRoot, `office-${manifest.version}.release.json`); + const archive = join(releaseRoot, `office-${manifest.version}.mpp.zip`); + const publisher = join(pluginRoot, "scripts/publish.mjs"); + expect(existsSync(publisher), "The Office registry publisher must be available").toBe(true); + const args = [publisher, "--help"]; + const help = execFileSync(process.execPath, args, { encoding: "utf8" }); + expect(help).toContain("--set-default"); + execFileSync(process.execPath, [publisher, "--root", root, "--descriptor", descriptor, "--artifact", archive, "--set-default"], { encoding: "utf8" }); + const registry = await serveRegistry({ root }); + cleanup.push(() => new Promise(ok => registry.server.close(() => ok()))); + const remote = createHttpPluginRegistry({ baseUrl: registry.baseUrl }); + const listed = await remote.list!(); + expect(listed.map(item => item.id)).toEqual(["office"]); + expect((await remote.resolve("office")).manifest.rules).toMatchObject({ installation: "manual", distribution: "public", requiresLogin: false }); + const bad = join(root, "bad.zip"); writeFileSync(bad, "tampered"); + expect(() => execFileSync(process.execPath, [publisher, "--root", root, "--descriptor", descriptor, "--artifact", bad], { stdio: "pipe" })).toThrow(); + }); + + it("installs the real release, runs all formats through MCP/Agent, renders actual pages, delivers files and unregisters on disable/uninstall", async () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "office-installed-e2e-"))); + cleanup.push(() => rmSync(root, { recursive: true, force: true })); + const workspace = join(root, "workspace"), uploads = join(root, "uploads"), dataRoot = join(root, "plugin-data"); + mkdirSync(workspace); mkdirSync(uploads); + const registry = await serveRegistry({ root: releaseRoot }); + cleanup.push(() => new Promise(ok => registry.server.close(() => ok()))); + const store: AppStateStore = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + cleanup.push(() => store.close()); + const installationStages: string[] = []; + const managed = process.env.OFFICE_E2E_MANAGED_RUNTIME === "1"; + if (managed) { + const previous = process.env.MEMMY_OFFICE_RUNTIME_PREFER_MANAGED; + process.env.MEMMY_OFFICE_RUNTIME_PREFER_MANAGED = "1"; + cleanup.push(() => { if (previous === undefined) delete process.env.MEMMY_OFFICE_RUNTIME_PREFER_MANAGED; else process.env.MEMMY_OFFICE_RUNTIME_PREFER_MANAGED = previous; }); + } + const installer = managed ? createOfficeRuntimeInstaller({ cacheRoot: join(root, "managed-runtime"), catalogUrl: `${registry.baseUrl}/api/v1/plugins/office/runtimes/{target}`, onProgress: stage => { installationStages.push(stage); } }) : undefined; + const runtime = createPluginRuntimeHost(new PluginAdapterRegistry([ + createCommandPluginAdapter({ fileInputRoots: [uploads], pluginDataRoot: dataRoot, officeRuntimeInstaller: installer }) + ])); + const service: PluginService = createPluginService({ repository: store.repositories.plugins, secretStore: store.secretStore, + registry: createHttpPluginRegistry({ baseUrl: registry.baseUrl }), runtimeHost: runtime, + artifactManager: createPluginArtifactManager({ installRoot: join(root, "installed") }), + skillManager: createPluginSkillManager({ skillsRoot: join(workspace, "skills") }), + localArtifactService: createPluginLocalArtifactService({ pluginDataRoot: dataRoot, resolveWorkspace: async () => workspace }), + isAuthenticated: () => false, isEntitlementGranted: () => false }); + cleanup.push(() => service.shutdown()); + const catalog = await service.catalog(); + expect(catalog[0]?.eligibility.canInstall).toBe(true); + const installed = await service.install("office"); + expect(installed.state).toBe("pending_approval"); + await service.approvePermissions("office", installed.manifest.permissions); + expect((await service.enable("office")).state).toBe("active"); + const progress = createProgressBus(); + const api = Fastify(); registerPluginRoutes(api, { plugins: service, progressBus: progress, authenticateRuntimeToken: async () => undefined }); + cleanup.push(() => api.close()); + const server = buildPluginMcpServer(service, progress); + const client = new Client({ name: "office-e2e", version: "1.0.0" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); await server.connect(st); await client.connect(ct); + cleanup.push(async () => { await client.close(); await server.close(); }); + const tools = (await client.listTools()).tools; + expect(tools).toHaveLength(installed.manifest.capabilities.length); + const toolName = (id: string) => tools.find(t => t.name.includes(`${id}_`))!.name; + const results: Array> = []; + const call = async (id: string, args: Record, expectedError?: string | readonly string[]) => { + console.info(`Office E2E: ${id} started`); + const result = await client.callTool({ name: toolName(id), arguments: args, _meta: { "memmy.dev/session-key": "office-e2e" } }, undefined, { timeout: 360000 }); + console.info(`Office E2E: ${id} completed`); + const text = (result.content as Array<{ type: string; text?: string }>).find(c => c.type === "text")?.text ?? ""; + if (expectedError) { + expect(result.isError).toBe(true); + if (typeof expectedError === "string") expect(text).toContain(expectedError); + else expect(expectedError.some(code => text.startsWith(`${code}:`)), text).toBe(true); + return {}; + } + expect(result.isError, `${id}: ${text}`).not.toBe(true); + const output = JSON.parse(text); results.push(output); + if (id === "office_review_page") expect((result.content as Array<{ type: string }>).some(c => c.type === "image")).toBe(true); + return output; + }; + if (managed) expect(installationStages).toContain("installed"); + const status = await call("office_status", {}); + expect(status.ready, JSON.stringify(status)).toBe(true); + const advanced = await call("office_execute", { code: ` + const document = new docx.Document({sections:[{children:[new docx.Table({rows:[new docx.TableRow({children:[new docx.TableCell({children:[new docx.Paragraph("Advanced template table")]})]})]})]}]}); + await fs.writeFile(path.join(workspace, "table.docx"), await docx.Packer.toBuffer(document)); + return {files:["table.docx"]}; + ` }); + expect((await call("office_inspect", { taskId: advanced.taskId, file: "table.docx" })).text).toContain("Advanced template table"); + for (const format of ["docx", "pdf", "pptx", "xlsx"]) { + const content = format === "pptx" ? { slides: [{ title: "Office integration", body: ["Independent plugin", "Actual page rendering"], notes: "Preserved speaker notes" }] } + : format === "xlsx" ? { sheets: [{ name: "Budget", rows: [["Item", "Amount"], ["Research", 20], ["Writing", 30]] }] } + : { title: "Office integration", paragraphs: ["This document verifies the standalone Office plugin.", "中文内容:文档生成、预览与交付。"] }; + let document = await call("office_create", { format, name: `sample.${format}`, ...content }); + const source = join(dataRoot, "office/tasks", document.taskId, document.file); + const sourceHash = sha(readFileSync(source)); + const summary = await call("office_inspect", { taskId: document.taskId, file: document.file }); + expect(summary.format).toBe(format); + if (format === "pdf") expect(summary.text).toContain("中文内容"); + if (format === "pptx") expect(summary.text).toContain("Preserved speaker notes"); + if (format === "docx" || format === "pptx") { + const originalZip = await JSZip.loadAsync(readFileSync(source)); + document = await call("office_edit", { taskId: document.taskId, file: document.file, name: `edited.${format}`, replacements: [{ from: "Office integration", to: "Office delivery verified" }] }); + const edited = await JSZip.loadAsync(readFileSync(join(dataRoot, "office/tasks", document.taskId, document.file))); + for (const name of Object.keys(originalZip.files).filter(n => !originalZip.files[n]!.dir && !/^(word\/document|ppt\/slides\/slide1)\.xml$/.test(n))) { + expect(await edited.file(name)!.async("nodebuffer"), `Untouched part ${name}`).toEqual(await originalZip.file(name)!.async("nodebuffer")); + } + expect((await call("office_inspect", { taskId: document.taskId, file: document.file })).text).toContain("Office delivery verified"); + } + if (format === "xlsx") { + document = await call("office_edit", { taskId: document.taskId, file: document.file, name: "calculation.xlsx", cells: [{ sheet: "Budget", address: "B4", formula: "SUM(B2:B3)" }] }); + const stale = await call("office_execute", { sourceTaskId: document.taskId, inputFiles: [document.file], code: ` + const book = new ExcelJS.Workbook(); await book.xlsx.readFile(path.join(workspace, ${JSON.stringify(document.file)})); + book.getWorksheet("Budget").getCell("B4").value = {formula:"SUM(B2:B3)",result:0}; + const checks = book.addWorksheet("Checks"); + checks.addRows([[{formula:"Budget!B4*2",result:999}], [{formula:'IF(A1=100,"OK","BAD")',result:"STALE"}], [{formula:"A1=100",result:false}], ["KEEP"]]); + await fs.writeFile(path.join(workspace,"stale-cache.xlsx"),await book.xlsx.writeBuffer()); + return {files:["stale-cache.xlsx"]}; + ` }); + document = { taskId: stale.taskId, file: stale.files[0] }; + const stalePath = join(dataRoot, "office/tasks", document.taskId, document.file); + const staleBytes = readFileSync(stalePath); + expect((await call("office_inspect", document)).formulas.map((f: { cachedValue: string }) => f.cachedValue)).toEqual(["0", "999", "STALE", "0"]); + await call("office_convert", { taskId: document.taskId, file: document.file, format: "xlsx" }, "preservation_consent_required"); + document = await call("office_convert", { taskId: document.taskId, file: document.file, format: "xlsx", allowLossy: true }); + expect(readFileSync(stalePath)).toEqual(staleBytes); + const calculated = await call("office_inspect", { taskId: document.taskId, file: document.file }); + expect(calculated.formulas.map((f: { cachedValue: string }) => f.cachedValue)).toEqual(["50", "100", "OK", "1"]); + expect(calculated.sheets.find((s: { name: string }) => s.name === "Checks").rows[3][0]).toBe("KEEP"); + const calculatedBytes = readFileSync(join(dataRoot, "office/tasks", document.taskId, document.file)); + const copied = await call("office_execute", { sourceTaskId: document.taskId, inputFiles: [document.file], code: ` + await fs.copyFile(path.join(workspace, ${JSON.stringify(document.file)}), path.join(workspace, "renamed-budget.xlsx")); + return {files:["renamed-budget.xlsx"]}; + ` }); + document = { taskId: copied.taskId, file: copied.files[0] }; + expect(readFileSync(join(dataRoot, "office/tasks", document.taskId, document.file))).toEqual(calculatedBytes); + await call("office_deliver", { taskId: document.taskId, file: document.file, revision: sha(calculatedBytes), reviewedPages: [1], reviewNotes: "not yet reviewed" }, "stale_review"); + } + const rendered = await call("office_render", { taskId: document.taskId, file: document.file }); + expect(rendered.pages.length).toBeGreaterThan(0); + const pageNumbers = rendered.pages.map((p: { page: number }) => p.page); + await call("office_deliver", { taskId: document.taskId, file: document.file, revision: rendered.revision, reviewedPages: pageNumbers, reviewNotes: "premature" }, "pages_not_viewed"); + for (const page of pageNumbers) { + const viewed = await call("office_review_page", { taskId: document.taskId, file: document.file, revision: rendered.revision, page }); + const preview = await api.inject({ method: "GET", url: viewed.artifacts[0].uri }); + expect(preview.statusCode).toBe(200); + expect(preview.rawPayload.subarray(1, 4).toString()).toBe("PNG"); + // Retain real renderings for a human/model visual review after the run. + const qaRoot = process.env.OFFICE_E2E_QA_DIR; + if (qaRoot) { mkdirSync(qaRoot, { recursive: true }); writeFileSync(join(qaRoot, `${format}-page-${page}.png`), preview.rawPayload); } + } + const delivered = await call("office_deliver", { taskId: document.taskId, file: document.file, revision: rendered.revision, reviewedPages: pageNumbers, + reviewNotes: "Automated transport check only; retained PNGs require separate visual review." }); + const artifact = delivered.artifacts[0] as PluginArtifactRef; + expect(dirname(realpathSync.native(artifact.path!))).toBe(realpathSync.native(join(workspace, "outputs/office", document.taskId))); + const preview = await api.inject({ method: "GET", url: artifact.uri }); + const download = await api.inject({ method: "GET", url: artifact.downloadUri! }); + expect(preview.statusCode).toBe(200); expect(download.statusCode).toBe(200); + expect(preview.rawPayload).toEqual(download.rawPayload); + expect(readFileSync(artifact.path!)).toEqual(download.rawPayload); + expect(sha(readFileSync(source))).toBe(sourceHash); + await call("office_review_page", { taskId: document.taskId, file: document.file, revision: "invalid", page: 1 }, "stale_review"); + } + + // Real AgentRunner + MCP wrapper, with a deterministic provider. This proves + // tool routing and image transport, not live-model selection or visual judgment. + const agentTools = new ToolRegistry(); + const session = { callTool: (name: string, args: Record, timeout: number, meta?: Record) => + client.callTool({ name, arguments: args, ...(meta ? { _meta: meta } : {}) }, undefined, { timeout: timeout * 1000 }) }; + const wrappers = new Map(); + for (const capability of installed.manifest.capabilities) { + const wrapper = new MCPToolWrapper(session, "plugins", tools.find(t => t.name === toolName(capability.id))!, 180); + agentTools.register(wrapper); wrappers.set(capability.id, wrapper); + } + let step = 0; let agentDocument: any; let agentRender: any; + const provider = { generation: { maxTokens: 512 }, getDefaultModel: () => "gpt-4o", + chatWithRetry: async ({ messages }: { messages: Array> }) => { + const latest = messages.filter(m => m.role === "tool").at(-1)?.content; + const text = Array.isArray(latest) ? latest.find(c => c.type === "text")?.text : latest; + const last = text ? JSON.parse(text) : {}; + const tool = (id: string, args: Record) => new LLMResponse({ content: null, finishReason: "tool_calls", + toolCalls: [new ToolCallRequest({ id: `office-agent-${step}`, name: wrappers.get(id)!.name, arguments: args })] }); + switch (step++) { + case 0: return tool("office_create", { format: "docx", name: "agent.docx", title: "Agent Office delivery", paragraphs: ["Generated through the actual Agent tool loop."] }); + case 1: agentDocument = last; return tool("office_render", { taskId: last.taskId, file: last.file }); + case 2: agentRender = last; expect(last.pages).toHaveLength(1); return tool("office_review_page", { taskId: last.taskId, file: last.file, revision: last.revision, page: 1 }); + case 3: + expect(Array.isArray(latest) && latest.some(c => c.type === "image_url")).toBe(true); + return tool("office_deliver", { taskId: agentDocument.taskId, file: agentDocument.file, revision: agentRender.revision, + reviewedPages: [1], reviewNotes: "Scripted Agent image transport test; visual review is performed separately." }); + default: expect(last.artifacts[0].downloadUri).toContain("/download"); return new LLMResponse({ content: "Office file delivered.", finishReason: "stop" }); + } + } }; + const context = new ContextBuilder({ workspace, fileMemoryEnabled: false }); + const system = context.buildSystemPrompt(["office-docx"]); + expect(system).toContain("office_review_page"); + expect(context.skills.listSkills().filter(s => s.name.startsWith("office-")).map(s => s.name)).toEqual(["office-docx", "office-pdf", "office-pptx", "office-xlsx"]); + const run = await new AgentRunner(provider as never).run(new AgentRunSpec({ provider: provider as never, tools: agentTools, model: "gpt-4o", maxIterations: 8, + messages: [{ role: "system", content: system }, { role: "user", content: "$office-docx Create a short Word report and deliver the file." }] })); + expect(run.finalContent).toBe("Office file delivered."); + expect(run.toolsUsed).toContain(wrappers.get("office_deliver")!.name); + + let liveRun; + if (process.env.OFFICE_LIVE_AGENT === "1") { + const configPath = resolve(process.env.MEMMY_CONFIG ?? join(homedir(), ".memmy/config.yaml")); + const config = YAML.parse(readFileSync(configPath, "utf8")); + const mode = config.modelAssignments?.account?.agent?.default ? "account" : "byok"; + const selected = resolveModelSelection({ configPath, mode, activeAccountId: mode === "account" ? config.app?.userId ?? null : null, capability: "agent" }); + if (!selected) throw new Error("No configured Memmy model is available for Office live Agent validation"); + const liveArtifacts: PluginArtifactRef[] = []; + const livePreviewArtifacts: PluginArtifactRef[] = []; + const unsubscribe = progress.on("plugin.capability_event", event => { + if (event.event.type === "artifact" && event.event.artifact.role === "deliverable") liveArtifacts.push(event.event.artifact); + if (event.event.type === "artifact" && event.event.artifact.role === "setup" && event.event.artifact.mediaType === "image/png") livePreviewArtifacts.push(event.event.artifact); + }); + try { + liveRun = await new AgentRunner(selected.snapshot.provider).run(new AgentRunSpec({ provider: selected.snapshot.provider, + model: selected.model, tools: agentTools, maxIterations: 20, + messages: [{ role: "system", content: system }, { role: "user", content: "$office-docx 请使用 Office 插件制作一页中文 Word 报告,标题为“Office 插件验收”,用一个两列表格列出文档处理、图像检查、文件交付三项能力。请先检查依赖,通过 office_execute 使用 docx 库生成可编辑表格,再读取检查、实际渲染,调用 office_review_page 观察每页图像;修复发现的问题后调用 office_deliver 交付文件。无需再次确认,不要只汇报计划。最后返回工具实际给出的下载链接。" }] })); + expect(liveRun.toolsUsed).toContain(wrappers.get("office_execute")!.name); + expect(liveRun.toolsUsed).toContain(wrappers.get("office_review_page")!.name); + expect(liveRun.toolsUsed).toContain(wrappers.get("office_deliver")!.name); + expect(liveArtifacts.length).toBeGreaterThan(0); + expect(existsSync(liveArtifacts.at(-1)!.path!)).toBe(true); + if (process.env.OFFICE_E2E_QA_DIR) { + const qaRoot = process.env.OFFICE_E2E_QA_DIR; mkdirSync(qaRoot, { recursive: true }); + writeFileSync(join(qaRoot, "live-agent-report.docx"), readFileSync(liveArtifacts.at(-1)!.path!)); + for (const [index, preview] of livePreviewArtifacts.entries()) { + const token = preview.uri.split("/").at(-2)!; + const opened = await service.openArtifact("office", token); + writeFileSync(join(qaRoot, `live-agent-page-${index + 1}.png`), readFileSync(opened.path)); + } + } + } finally { unsubscribe(); } + } + + // Imported files are copied and never altered; forbidden formats/paths fail. + const uploaded = join(uploads, "uploaded.docx"); + writeFileSync(uploaded, readFileSync(join(dataRoot, "office/tasks", agentDocument.taskId, "agent.docx"))); + const uploadHash = sha(readFileSync(uploaded)); + const imported = await call("office_import", { path: uploaded }); + expect(imported.sourceUnchanged).toBe(true); expect(sha(readFileSync(uploaded))).toBe(uploadHash); + await call("office_inspect", { taskId: imported.taskId, file: "../uploaded.docx" }, "invalid_path"); + // The Unix system path does not exist on Windows, where ENOENT proves + // nothing about sandbox denial. The owned, existing fixture below covers + // denied imports on every platform without relying on OS-specific paths. + if (process.platform !== "win32") await call("office_import", { path: "/etc/passwd" }, "EPERM"); + const deniedPath = join(root, "outside.docx"); writeFileSync(deniedPath, readFileSync(uploaded)); + expect(existsSync(deniedPath)).toBe(true); + const deniedHash = sha(readFileSync(deniedPath)); + // Both OS-level access denial and the plugin's approved-root guard reject + // this file; missing-file and unrelated errors must not satisfy the test. + await call("office_import", { path: deniedPath }, ["EPERM", "EACCES", "unsafe_file"]); + expect(sha(readFileSync(deniedPath))).toBe(deniedHash); + + await service.disable("office"); + expect((await client.listTools()).tools).toEqual([]); + expect(existsSync(join(workspace, "skills/office-docx"))).toBe(false); + expect((await client.callTool({ name: toolName("office_status"), arguments: {} })).isError).toBe(true); + await service.enable("office"); expect((await client.listTools()).tools).toHaveLength(tools.length); + await service.uninstall("office"); expect((await client.listTools()).tools).toEqual([]); + expect(existsSync(join(workspace, "skills/office-xlsx"))).toBe(false); + const report = process.env.OFFICE_E2E_REPORT; + if (report) writeFileSync(report, JSON.stringify({ result: "passed", pluginVersion: installed.version, platform: process.platform, realSandbox: true, realRenderer: true, + managedRuntime: managed, runtimeInstallationStages: [...new Set(installationStages)], + liveModel: Boolean(liveRun), visualReview: liveRun ? "Live Agent inspected rendered pages" : "PNG inspection required separately", formats: ["docx", "pdf", "pptx", "xlsx"], steps: results.length, agentTools: run.toolsUsed, + ...(liveRun ? { liveAgentTools: liveRun.toolsUsed, liveAgentFinal: liveRun.finalContent } : {}) }, null, 2)); + }, process.env.OFFICE_LIVE_AGENT === "1" ? 900000 : process.platform === "win32" ? 1800000 : 600000); +}); diff --git a/App/backend/src/tests/plugin-host-e2e.test.ts b/App/backend/src/tests/plugin-host-e2e.test.ts new file mode 100644 index 000000000..035d52c17 --- /dev/null +++ b/App/backend/src/tests/plugin-host-e2e.test.ts @@ -0,0 +1,490 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildPluginMcpServer } from "../adapters/inbound/local-api/routes/plugin-mcp.js"; +import { createInMemoryPluginRegistry } from "../adapters/outbound/plugin-registry/index.js"; +import { + createCommandPluginAdapter, + createHttpPluginAdapter, + createPluginRuntimeHost, + PluginAdapterRegistry +} from "../adapters/outbound/plugin-runtime/index.js"; +import { createPluginSkillManager } from "../adapters/outbound/plugin-skill/index.js"; +import { createPluginAsrService } from "../services/plugin-asr-service.js"; +import { createPluginHostServiceRouter } from "../services/plugin-host-service-router.js"; +import { createAppStateStore, type AppStateStore } from "../infrastructure/app-state-store/index.js"; +import { createPluginService } from "../services/plugin-service.js"; +import { createPluginLocalArtifactService } from "../services/plugin-local-artifact-service.js"; +import { createProgressBus } from "../services/progress-bus.js"; + +let root: string | undefined; +let store: AppStateStore | undefined; +let client: Client | undefined; +let mcpServer: ReturnType | undefined; + +afterEach(async () => { + await client?.close(); + await mcpServer?.close(); + client = undefined; + mcpServer = undefined; + store?.close(); + store = undefined; + if (root) rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +describe("plugin host end to end", () => { + it("installs a generic review plugin and invokes it from Agent", async () => { + const fetchFn = vi.fn(async () => new Response([ + JSON.stringify({ type: "progress", current: 1, total: 1, message: "searching" }), + JSON.stringify({ type: "result", output: { review: "Agent Memory review" } }) + ].join("\n"), { headers: { "content-type": "application/x-ndjson" } })); + root = mkdtempSync(join(tmpdir(), "memmy-plugin-e2e-")); + store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + const runtimeHost = createPluginRuntimeHost(new PluginAdapterRegistry([ + createHttpPluginAdapter({ fetchFn: fetchFn as typeof fetch }) + ])); + const service = createPluginService({ + repository: store.repositories.plugins, + secretStore: store.secretStore, + registry: createInMemoryPluginRegistry([{ + manifest: { + apiVersion: "memmy/v1", + id: "com.example.review", + name: "Literature Review", + version: "1.0.0", + runtime: { + adapter: "http", + config: { + baseUrl: "https://review.example", + secretHeaders: { authorization: "api-key" } + } + }, + capabilities: [{ + id: "write-review", + name: "Write review", + description: "Search papers and write a literature review", + inputSchema: { + type: "object", + properties: { topic: { type: "string" } }, + required: ["topic"] + }, + outputSchema: { + type: "object", + properties: { review: { type: "string" } }, + required: ["review"] + }, + execution: "job" + }], + permissions: [ + { type: "network", hosts: ["review.example"] }, + { type: "secret", keys: ["api-key"] } + ], + configSchema: { + type: "object", + properties: { database: { type: "string" } }, + required: ["database"] + } + } + }]), + runtimeHost, + artifactManager: { + install: async () => ({ artifactHash: null, rootPath: null }), + readTextFile: async () => "", + remove: async () => undefined + } + }); + + await service.install("com.example.review"); + service.configure("com.example.review", { + config: { database: "crossref" }, + secrets: { "api-key": "Bearer secret" } + }); + await service.approvePermissions("com.example.review", service.get("com.example.review").manifest.permissions); + await service.enable("com.example.review"); + + mcpServer = buildPluginMcpServer(service, createProgressBus()); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + client = new Client({ name: "agent-test", version: "1.0.0" }); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + const tools = await client.listTools(); + const result = await client.callTool({ + name: tools.tools[0]!.name, + arguments: { topic: "Agent Memory" }, + _meta: { "memmy.dev/session-key": "desktop:review-session" } + }); + + expect(result).toMatchObject({ structuredContent: { review: "Agent Memory review" } }); + const [, request] = fetchFn.mock.calls[0]!; + expect((request?.headers as Record).authorization).toBe("Bearer secret"); + expect(JSON.parse(String(request?.body))).toMatchObject({ + conversationId: "desktop:review-session", + input: { topic: "Agent Memory" }, + config: { database: "crossref" } + }); + expect(store.db.prepare("SELECT outcome FROM plugin_call_logs").get()).toEqual({ outcome: "success" }); + }); + + it("runs the command-plugin vertical slice through file input, Skill, UI, artifact hosting, and disable", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-review-plugin-e2e-")); + const artifactRoot = join(root, "plugin"); + const mediaRoot = join(root, "agent-data", "media"); + const pluginDataRoot = join(root, "plugin-data"); + const skillsRoot = join(root, "workspace", "skills"); + mkdirSync(join(artifactRoot, "runtime"), { recursive: true }); + mkdirSync(join(artifactRoot, "skills", "literature-review"), { recursive: true }); + mkdirSync(join(artifactRoot, "ui"), { recursive: true }); + mkdirSync(mediaRoot, { recursive: true }); + writeFileSync(join(artifactRoot, "package.json"), JSON.stringify({ type: "module" })); + writeFileSync(join(artifactRoot, "skills", "literature-review", "SKILL.md"), "---\nname: literature-review\ndescription: Test review\n---\nUse the review tools.\n"); + writeFileSync(join(artifactRoot, "ui", "review.html"), "
Review card
"); + writeFileSync(join(artifactRoot, "runtime", "command.js"), commandPluginFixture()); + const sourcePath = join(mediaRoot, "paper.pdf"); + writeFileSync(sourcePath, "paper bytes"); + + store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + const runtimeHost = createPluginRuntimeHost(new PluginAdapterRegistry([ + createCommandPluginAdapter({ + allowedNetworkHosts: ["export.arxiv.org", "arxiv.org"], + fileInputRoots: [mediaRoot], + pluginDataRoot, + buildLaunch: async (context, config, networkEnabled) => { + expect(networkEnabled).toBe(true); + return { + command: process.execPath, + args: [join(context.rootPath!, config.command)], + cwd: context.rootPath! + }; + } + }) + ])); + const manifest = commandReviewManifest(); + const service = createPluginService({ + repository: store.repositories.plugins, + secretStore: store.secretStore, + registry: createInMemoryPluginRegistry([{ manifest }]), + runtimeHost, + artifactManager: { + install: async () => ({ artifactHash: "fixture", rootPath: artifactRoot }), + readTextFile: async (_plugin, relativePath) => readFileSync(join(artifactRoot, relativePath), "utf8"), + remove: async () => undefined + }, + skillManager: createPluginSkillManager({ skillsRoot }), + localArtifactService: createPluginLocalArtifactService({ pluginDataRoot }) + }); + + await service.install(manifest.id); + service.configure(manifest.id, { config: {}, secrets: {} }); + await service.approvePermissions(manifest.id, manifest.permissions); + await service.enable(manifest.id); + expect(readFileSync(join(skillsRoot, "literature-review", "SKILL.md"), "utf8")).toContain("Use the review tools"); + await expect(service.readUi(manifest.id, "renderer")).resolves.toContain("Review card"); + + mcpServer = buildPluginMcpServer(service, createProgressBus()); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + client = new Client({ name: "agent-test", version: "1.0.0" }); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + expect((await client.listTools()).tools).toHaveLength(1); + + const iterator = service.invoke({ + callId: "call-import", + pluginId: manifest.id, + capabilityId: "review_import_sources", + conversationId: "chat-1", + input: {} + })[Symbol.asyncIterator](); + const interaction = await iterator.next(); + expect(interaction.value).toMatchObject({ + type: "interaction", + request: { type: "file-input" } + }); + const interactionId = interaction.value?.type === "interaction" ? interaction.value.request.interactionId : ""; + await service.respond(manifest.id, "call-import", interactionId, { + files: [{ path: sourcePath, name: "paper.pdf" }] + }); + const artifact = await iterator.next(); + expect(artifact.value).toMatchObject({ + type: "artifact", + artifact: { + name: "review.md", + uri: expect.stringMatching(/^\/api\/v1\/plugins\/literature-review\/artifacts\/[^/]+\/preview$/), + downloadUri: expect.stringMatching(/\/download$/) + } + }); + const hostedUri = artifact.value?.type === "artifact" ? artifact.value.artifact.uri : ""; + const token = hostedUri.split("/").at(-2)!; + const hosted = await service.openArtifact(manifest.id, token); + expect(readFileSync(hosted.path, "utf8")).toContain("Imported paper.pdf"); + await expect(iterator.next()).resolves.toMatchObject({ value: { type: "result", output: { ok: true } } }); + + await service.disable(manifest.id); + expect(existsSync(join(skillsRoot, "literature-review"))).toBe(false); + expect((await client.listTools()).tools).toHaveLength(0); + }); + + it("gates a closed-source plugin on its entitlement and runs its recording flow unsandboxed", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-sealed-plugin-e2e-")); + const artifactRoot = join(root, "plugin"); + const mediaRoot = join(root, "agent-data", "media"); + const pluginDataRoot = join(root, "plugin-data"); + mkdirSync(join(artifactRoot, "runtime"), { recursive: true }); + mkdirSync(mediaRoot, { recursive: true }); + writeFileSync(join(artifactRoot, "package.json"), JSON.stringify({ type: "module" })); + writeFileSync(join(artifactRoot, "runtime", "command.js"), recordingPluginFixture()); + const recordingPath = join(mediaRoot, "interview.webm"); + writeFileSync(recordingPath, "opus bytes"); + + store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + const manifest = sealedRecordingManifest(); + let granted: string[] = []; + const isEntitlementGranted = (entitlement: string) => granted.includes(entitlement); + let spawnedCommand = ""; + const transcribe = vi.fn(async () => ({ + text: "Q: start date? A: March 2021.", + modelId: "qwen-audio-3.0-asr-flash-filetrans", + provider: "memmy_account" as const, + source: "account" as const, + transcribedAt: new Date().toISOString(), + segments: [ + { speakerId: 0, text: "start date?", startMs: 0, endMs: 1200 }, + { speakerId: 1, text: "March 2021.", startMs: 1200, endMs: 2600 } + ] + })); + const runtimeHost = createPluginRuntimeHost(new PluginAdapterRegistry([ + createCommandPluginAdapter({ + fileInputRoots: [mediaRoot], + pluginDataRoot, + isEntitlementGranted, + hostServices: createPluginHostServiceRouter([{ + services: ["asr"], + invoker: createPluginAsrService({ asr: { transcribe } as never, audioRoots: [mediaRoot] }) + }]), + spawnFn: ((command, args, options) => { + spawnedCommand = command; + return spawn(command, args, options as Parameters[2]) as ReturnType; + }) as typeof spawn + }) + ])); + const service = createPluginService({ + repository: store.repositories.plugins, + secretStore: store.secretStore, + registry: createInMemoryPluginRegistry([{ manifest }]), + runtimeHost, + artifactManager: { + install: async () => ({ artifactHash: "fixture", rootPath: artifactRoot }), + readTextFile: async (_plugin, relativePath) => readFileSync(join(artifactRoot, relativePath), "utf8"), + remove: async () => undefined + }, + localArtifactService: createPluginLocalArtifactService({ pluginDataRoot }), + isEntitlementGranted + }); + + // Without the grant the release may be reachable but must not install. + await expect(service.install(manifest.id)).rejects.toMatchObject({ code: "plugin_entitlement_required" }); + + granted = ["plugin:legal-labor"]; + await service.install(manifest.id); + service.configure(manifest.id, { config: {}, secrets: {} }); + await service.approvePermissions(manifest.id, manifest.permissions); + await service.enable(manifest.id); + + const iterator = service.invoke({ + callId: "call-record", + pluginId: manifest.id, + capabilityId: "collect_interview", + conversationId: "chat-1", + input: {} + })[Symbol.asyncIterator](); + const interaction = await iterator.next(); + expect(interaction.value).toMatchObject({ + type: "interaction", + request: { type: "audio-record" } + }); + + // The card records, the Host transcribes, and the plugin only ever sees + // paths: it holds neither the microphone nor the ASR credentials. + const interactionId = interaction.value?.type === "interaction" ? interaction.value.request.interactionId : ""; + await service.respond(manifest.id, "call-record", interactionId, { + audio: { path: recordingPath, name: "interview.webm", mime: "audio/webm" }, + transcript: { path: recordingPath, name: "interview.txt", mime: "text/plain" } + }); + + await expect(iterator.next()).resolves.toMatchObject({ + value: { + type: "result", + output: { speakers: [0, 1], text: "Q: start date? A: March 2021." } + } + }); + // Speaker separation is the whole reason this goes through the Host service. + expect(transcribe).toHaveBeenCalledWith(expect.objectContaining({ diarization: true })); + expect(spawnedCommand).not.toMatch(/sandbox-exec|bwrap/u); + + // Revocation has to bite before the reconciler gets around to disabling + // the plugin, otherwise a revoked account keeps running the copy it + // already downloaded. + granted = []; + const revoked = service.invoke({ + callId: "call-after-revoke", + pluginId: manifest.id, + capabilityId: "collect_interview", + conversationId: "chat-1", + input: {} + })[Symbol.asyncIterator](); + await expect(revoked.next()).rejects.toMatchObject({ code: "plugin_entitlement_required" }); + }); +}); + +function commandReviewManifest() { + return { + apiVersion: "memmy/v1" as const, + id: "literature-review", + name: "Literature Review", + version: "0.2.0", + runtime: { + adapter: "command" as const, + config: { + command: "runtime/command.js", + interpreter: "node", + inputMode: "stdin-json", + outputMode: "ndjson", + interactive: true + } + }, + capabilities: [{ + id: "review_import_sources", + name: "Import sources", + description: "Import selected literature and produce a task artifact.", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "job" as const + }], + permissions: [ + { type: "network" as const, hosts: ["export.arxiv.org", "arxiv.org"] }, + { type: "host-service" as const, services: ["file-input", "plugin-data", "artifact-host"] } + ], + configSchema: { type: "object", additionalProperties: false }, + skills: [{ + id: "literature-review", + name: "Literature Review", + description: "Coordinate review tools.", + entry: "skills/literature-review/SKILL.md" + }], + ui: { renderer: { entry: "ui/review.html", capabilities: ["review_import_sources"] } } + }; +} + +function commandPluginFixture(): string { + return ` +import { createInterface } from "node:readline"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); +const iterator = lines[Symbol.asyncIterator](); +const call = JSON.parse((await iterator.next()).value); +const interactionId = "source-files"; +process.stdout.write(JSON.stringify({ + type: "interaction", + request: { + interactionId, + type: "file-input", + payload: { title: "Select papers", accept: [".pdf"], multiple: true }, + responseSchema: { + type: "object", + required: ["files"], + properties: { files: { type: "array", minItems: 1, items: { type: "object", required: ["path", "name"] } } } + } + } +}) + "\\n"); +const response = JSON.parse((await iterator.next()).value).response; +const source = response.files[0]; +readFileSync(source.path); +const outputPath = join(process.env.MEMMY_PLUGIN_DATA_DIR, "review.md"); +writeFileSync(outputPath, "# Review\\n\\nImported " + source.name + "\\n"); +process.stdout.write(JSON.stringify({ + type: "artifact", + artifact: { id: "review", name: "review.md", mediaType: "text/markdown", uri: pathToFileURL(outputPath).href } +}) + "\\n"); +process.stdout.write(JSON.stringify({ type: "result", output: { ok: true } }) + "\\n"); +lines.close(); +`; +} + + +function sealedRecordingManifest() { + return { + apiVersion: "memmy/v1" as const, + id: "closed-source-interview", + name: "Interview Collector", + version: "1.0.0", + requiredEntitlement: "plugin:legal-labor", + modelPolicy: { requiredSource: "account" as const }, + runtime: { + adapter: "command" as const, + config: { + command: "runtime/command.js", + interpreter: "node", + inputMode: "stdin-json", + outputMode: "ndjson", + interactive: true, + sandbox: "none" as const + } + }, + capabilities: [{ + id: "collect_interview", + name: "Collect interview", + description: "Record an interview and transcribe it with speaker separation.", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + execution: "job" as const + }], + permissions: [ + { type: "host-service" as const, services: ["asr"] } + ], + configSchema: { type: "object", additionalProperties: false } + }; +} + +function recordingPluginFixture(): string { + return ` +import { createInterface } from "node:readline"; +const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); +const iterator = lines[Symbol.asyncIterator](); +JSON.parse((await iterator.next()).value); +process.stdout.write(JSON.stringify({ + type: "interaction", + request: { + interactionId: "interview-recording", + type: "audio-record", + payload: { title: "Record the interview" }, + responseSchema: { + type: "object", + required: ["audio"], + properties: { audio: { type: "object", required: ["path"] } } + } + } +}) + "\\n"); +const response = JSON.parse((await iterator.next()).value).response; +process.stdout.write(JSON.stringify({ + type: "host-service-request", + requestId: "asr-1", + service: "asr", + input: { path: response.audio.path, mimeType: response.audio.mime, diarization: true } +}) + "\\n"); +const transcription = JSON.parse((await iterator.next()).value).response; +process.stdout.write(JSON.stringify({ + type: "result", + output: { + text: transcription.text, + speakers: [...new Set(transcription.segments.map((segment) => segment.speakerId))] + } +}) + "\\n"); +lines.close(); +`; +} diff --git a/App/backend/src/tests/private-plugin-skill-e2e.test.ts b/App/backend/src/tests/private-plugin-skill-e2e.test.ts new file mode 100644 index 000000000..2cea45c58 --- /dev/null +++ b/App/backend/src/tests/private-plugin-skill-e2e.test.ts @@ -0,0 +1,67 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { expect, it, vi } from "vitest"; +import { createPluginSkillContentService } from "../services/plugin-skill-content-service.js"; +import { createProgressBus } from "../services/progress-bus.js"; +import { buildPluginMcpServer, capabilityToolName } from "../adapters/inbound/local-api/routes/plugin-mcp.js"; +import { ToolRegistry } from "../../../memmy-agent/src/core/agent-runtime/tools/registry.js"; +import { MCPToolWrapper } from "../../../memmy-agent/src/core/agent-runtime/tools/mcp.js"; +import { AgentRunSpec, AgentRunner } from "../../../memmy-agent/src/core/agent-runtime/runner.js"; +import { LLMResponse, ToolCallRequest } from "../../../memmy-agent/src/providers/base.js"; + +it("loads cloud instructions through the real bridge without persisting bodies and reloads after restart", async () => { + const secret = "PRIVATE-CLOUD-TO-MODEL-SENTINEL"; + const payload = JSON.stringify({ format: "memmy-plugin-skill-v1", pluginId: "review", skillId: "review", pluginVersion: "1.0.0", revision: "r1", files: { "SKILL.md": secret, "references/evidence-policy.md": "PRIVATE-EVIDENCE-SENTINEL" } }); + const sha256 = createHash("sha256").update(payload).digest("hex"); + const plugin: any = { id: "review", version: "1.0.0", state: "active", manifest: { id: "review", name: "Review", skills: [{ id: "review", cloud: { revision: "r1", sha256 } }], capabilities: [{ id: "run", name: "Run", description: "Run", inputSchema: { type: "object" } }] } }; + const fetchFn = vi.fn(async () => new Response(payload)); + const content = createPluginSkillContentService({ baseUrl: "https://cloud.test.invalid", getPlugin: () => plugin, authHeaders: () => ({ authorization: "Bearer fixture-account" }), fetch: fetchFn as any }); + const root = mkdtempSync(join(tmpdir(), "memmy-private-skill-e2e-")); + const connections: Array<{ client: Client; server: ReturnType }> = []; + const events: unknown[] = []; const saved: unknown[] = []; const checkpoints: unknown[] = []; + const invoke = vi.fn(async function* () { yield { type: "result" as const, output: { ok: true } }; }); + async function tools() { + const bus = createProgressBus(); bus.on("plugin.capability_event", event => events.push(event)); + const server = buildPluginMcpServer({ list: () => [plugin], invoke, cancel: async () => {} }, bus, undefined, content); + const [a, b] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test", version: "1" }); + await server.connect(b); await client.connect(a); connections.push({ client, server }); + const session = { callTool: (name: string, args: any, _timeout: number, meta: any) => client.callTool({ name, arguments: args, _meta: meta }) }; + const registry = new ToolRegistry(); + for (const tool of (await client.listTools()).tools) registry.register(new MCPToolWrapper(session, "plugins", tool)); + return registry; + } + try { + const requests: any[] = []; + const runTool = `mcp_plugins_${capabilityToolName("review", "run")}`; + const responses = [ + new LLMResponse({ content: null, toolCalls: [new ToolCallRequest({ id: "load", name: "mcp_plugins_read_plugin_skill", arguments: { pluginId: "review", skillId: "review" } })] }), + new LLMResponse({ content: null, toolCalls: [new ToolCallRequest({ id: "run", name: runTool })] }), + new LLMResponse({ content: "review completed" }) + ]; + const provider = { chat: async (args: any) => { requests.push(args); return responses.shift()!; } }; + const result = await new AgentRunner(provider as any).run(new AgentRunSpec({ messages: [{ role: "user", content: "write a review" }], provider: provider as any, + tools: await tools(), sessionKey: "chat", maxIterations: 4, onPrivateSkillRefsChanged: refs => saved.push(structuredClone(refs)), + checkpointCallback: checkpoint => { checkpoints.push(structuredClone(checkpoint)); } })); + expect(invoke).toHaveBeenCalledTimes(1); expect(fetchFn).toHaveBeenCalledTimes(1); + expect(JSON.stringify(requests[0])).not.toContain(secret); + expect(JSON.stringify(requests[1])).toContain(secret); expect(JSON.stringify(requests[2])).toContain(secret); + writeFileSync(join(root, "session.json"), JSON.stringify({ messages: result.messages, metadata: { privatePluginSkillRefs: saved.at(-1) }, checkpoints, events })); + const transcript = readFileSync(join(root, "session.json"), "utf8"); + expect(transcript).not.toContain(secret); expect(transcript).not.toContain("PRIVATE-EVIDENCE-SENTINEL"); + expect(transcript).toContain("memmy_plugin_skill_ref_v1"); + content.clear(); // Simulated Host restart: all instruction memory is lost. + const resumedRequests: any[] = []; + const resumedProvider = { chat: async (args: any) => { resumedRequests.push(args); return new LLMResponse({ content: "resumed" }); } }; + const restored = JSON.parse(transcript); + const resumed = await new AgentRunner(resumedProvider as any).run(new AgentRunSpec({ messages: [{ role: "user", content: "continue after compaction" }], + privateSkillRefs: restored.metadata.privatePluginSkillRefs, tools: await tools(), sessionKey: "chat", provider: resumedProvider as any, maxIterations: 1 })); + expect(fetchFn).toHaveBeenCalledTimes(2); expect(JSON.stringify(resumedRequests)).toContain(secret); + expect(JSON.stringify(resumed.messages)).not.toContain(secret); + } finally { + content.clear(); await Promise.all(connections.flatMap(({ client, server }) => [client.close(), server.close()])); rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/App/backend/src/tests/support/mock-cloud-client.ts b/App/backend/src/tests/support/mock-cloud-client.ts index 62fbfb50d..eee231b47 100644 --- a/App/backend/src/tests/support/mock-cloud-client.ts +++ b/App/backend/src/tests/support/mock-cloud-client.ts @@ -89,6 +89,7 @@ export function createMockCloudClient(options: CreateMockCloudClientOptions = {} planType: "mock", hasFinishedGuide: false, improvementProgramGranted: false, + entitlements: null, region: null, registeredAt, rawProfile: { @@ -259,6 +260,7 @@ function createDefaultProfile(registeredAt: string): CloudAccountProfile { planType: "mock", hasFinishedGuide: false, improvementProgramGranted: false, + entitlements: null, region: null, registeredAt, rawProfile: { diff --git a/App/frontend/desktop/package.json b/App/frontend/desktop/package.json index d976a93c9..ebc9fb740 100644 --- a/App/frontend/desktop/package.json +++ b/App/frontend/desktop/package.json @@ -7,9 +7,12 @@ "dev": "vite", "build": "npm run build -w @memmy/knowledge && npm run build -w @memmy/desktop-interface && tsc -p tsconfig.json --noEmit && vite build", "typecheck": "npm run build -w @memmy/knowledge && npm run build -w @memmy/desktop-interface && tsc -p tsconfig.json --noEmit", - "test": "npm run build -w @memmy/knowledge && npm run build -w @memmy/desktop-interface && vitest run" + "test": "npm run build -w @memmy/knowledge && npm run build -w @memmy/desktop-interface && npm run utilities:check && vitest run", + "utilities:sync": "node scripts/sync-prototype-utilities.mjs", + "utilities:check": "node scripts/sync-prototype-utilities.mjs --check" }, "dependencies": { + "@aiden0z/pptx-renderer": "1.3.0", "@memmy/desktop-interface": "0.0.0", "@memmy/knowledge": "0.0.0", "@memmy/local-api-contracts": "0.0.0", @@ -17,8 +20,10 @@ "@vitejs/plugin-react": "^6.0.2", "@xyflow/react": "^12.11.2", "electron-log": "^5.4.4", + "jszip": "^3.10.2", "katex": "^0.16.47", "lucide-react": "^1.16.0", + "pdfjs-dist": "5.4.624", "monaco-editor": "^0.57.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -34,6 +39,7 @@ "devDependencies": { "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", - "happy-dom": "^20.10.6" + "happy-dom": "^20.10.6", + "postcss": "8.5.15" } } diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/LICENSE b/App/frontend/desktop/public/licenses/pptx-renderer/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/README.md b/App/frontend/desktop/public/licenses/pptx-renderer/README.md new file mode 100644 index 000000000..8305cf9d9 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/README.md @@ -0,0 +1,14 @@ +# PPTX preview notices + +These notices ship with the desktop renderer assets for the PPTX preview. + +- `@aiden0z/pptx-renderer` 1.3.0: Apache-2.0, see `LICENSE`. + Project: https://github.com/aiden0z/pptx-renderer +- ECharts 6.1.0: Apache-2.0, see `echarts/LICENSE` and `echarts/NOTICE`. +- ZRender 6.1.0: BSD-3-Clause, see `zrender/LICENSE`. +- tslib 2.3.0: 0BSD, see `tslib/LICENSE.txt` and `tslib/CopyrightNotice.txt`. + +The renderer includes unmodified `mtx-decompressor` 1.4.2 and mechanically +derived ECMA-376 geometry definitions. Their license copies, source links, +and provenance are retained in `THIRD_PARTY_NOTICES.md`, `licenses/`, and +`scripts/ooxml-geometry/source-manifest.json`. diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/THIRD_PARTY_NOTICES.md b/App/frontend/desktop/public/licenses/pptx-renderer/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..d8c358906 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/THIRD_PARTY_NOTICES.md @@ -0,0 +1,40 @@ +# Third-Party Notices + +The published ESM, CommonJS, and standalone browser bundles include the following +third-party software. + +## mtx-decompressor 1.4.2 + +`mtx-decompressor` is distributed under the Mozilla Public License 2.0 (MPL-2.0). +Its code is bundled without source modifications; bundling and minification produce +executable forms of the MPL-covered files. + +- Project: +- Corresponding source for the bundled version: + +- Human-readable source tag: + +- License copy: [`licenses/mtx-decompressor-MPL-2.0.txt`](licenses/mtx-decompressor-MPL-2.0.txt) + +The Apache-2.0 license for `@aiden0z/pptx-renderer` does not replace or restrict the +MPL-2.0 terms that apply to the covered `mtx-decompressor` source files. + +## ECMA-376 DrawingML Geometry Definitions + +The development tooling contains an unchanged copy of +`OfficeOpenXML-DrawingMLGeometries.zip` from ECMA-376 Part 1, 5th edition (2016). +It is used to validate and generate preset-shape metadata. The archive itself is excluded +from the published npm package and runtime bundles. The package includes the machine-readable +provenance manifest and a generated runtime subset containing a mechanical representation of +28 preset definitions; neither artifact contains the source archive. + +- Standard: +- Copyright policy: + +- Copyright notice: [`licenses/ECMA-text-copyright-notice.txt`](licenses/ECMA-text-copyright-notice.txt) +- Source provenance: [`scripts/ooxml-geometry/source-manifest.json`](scripts/ooxml-geometry/source-manifest.json) + +Copyright © Ecma International. The repository copy is preserved unchanged. The ECMA notice +and copyright policy apply to material copied or mechanically derived from the standard. +Independently authored renderer and evaluation code remains licensed under this project's +Apache-2.0 license. diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/echarts/LICENSE b/App/frontend/desktop/public/licenses/pptx-renderer/echarts/LICENSE new file mode 100644 index 000000000..c63376530 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/echarts/LICENSE @@ -0,0 +1,222 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + + +======================================================================== +Apache ECharts Subcomponents: + +The Apache ECharts project contains subcomponents with separate copyright +notices and license terms. Your use of the source code for these +subcomponents is also subject to the terms and conditions of the following +licenses. + +BSD 3-Clause (d3.js): +The following files embed [d3.js](https://github.com/d3/d3) BSD 3-Clause: + `/src/chart/treemap/treemapLayout.ts`, + `/src/chart/tree/layoutHelper.ts`, + `/src/chart/graph/forceHelper.ts`, + `/src/util/number.ts` +See `/licenses/LICENSE-d3` for details of the license. diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/echarts/NOTICE b/App/frontend/desktop/public/licenses/pptx-renderer/echarts/NOTICE new file mode 100644 index 000000000..c6a6e5e43 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/echarts/NOTICE @@ -0,0 +1,5 @@ +Apache ECharts +Copyright 2017-2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (https://www.apache.org/). \ No newline at end of file diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/licenses/ECMA-text-copyright-notice.txt b/App/frontend/desktop/public/licenses/pptx-renderer/licenses/ECMA-text-copyright-notice.txt new file mode 100644 index 000000000..cf7f4b476 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/licenses/ECMA-text-copyright-notice.txt @@ -0,0 +1,48 @@ +COPYRIGHT NOTICE + +© Ecma International + +By obtaining and/or copying this work, you (the licensee) agree that you have +read, understood, and will comply with the following terms and conditions. + +This document may be copied, published and distributed to others, and certain +derivative works of it may be prepared, copied, published, and distributed, in +whole or in part, provided that the above copyright notice and this Copyright +License and Disclaimer are included on all such copies and derivative works. +The only derivative works that are permissible under this Copyright License and +Disclaimer are: + +(i) works which incorporate all or portion of this document for the purpose of +providing commentary or explanation (such as an annotated version of the +document), + +(ii) works which incorporate all or portion of this document for the purpose of +incorporating features that provide accessibility, + +(iii) translations of this document into languages other than English and into +different formats and + +(iv) works by making use of this specification in standard conformant products +by implementing (e.g. by copy and paste wholly or partly) the functionality +therein. + +However, the content of this document itself may not be modified in any way, +including by removing the copyright notice or references to Ecma International, +except as required to translate it into languages other than English or into a +different format. + +The official version of an Ecma International document is the English language +version on the Ecma International website. In the event of discrepancies between +a translated version and the official version, the official version shall govern. + +The limited permissions granted above are perpetual and will not be revoked by +Ecma International or its successors or assigns. + +This document and the information contained herein is provided on an "AS IS" +basis and ECMA INTERNATIONAL DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF THE INFORMATION HEREIN +WILL NOT INFRINGE ANY OWNERSHIP RIGHTS OR ANY IMPLIED WARRANTIES OF +MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +Source: https://ecma-international.org/policies/by-ipr/ecma-text-copyright-policy/ +Retrieved: 2026-09-08 diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/licenses/mtx-decompressor-MPL-2.0.txt b/App/frontend/desktop/public/licenses/pptx-renderer/licenses/mtx-decompressor-MPL-2.0.txt new file mode 100644 index 000000000..a612ad981 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/licenses/mtx-decompressor-MPL-2.0.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/scripts/ooxml-geometry/source-manifest.json b/App/frontend/desktop/public/licenses/pptx-renderer/scripts/ooxml-geometry/source-manifest.json new file mode 100644 index 000000000..66b5293d5 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/scripts/ooxml-geometry/source-manifest.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "standard": { + "id": "ECMA-376-1:2016", + "part": 1, + "edition": 5, + "publicationLabel": "5th edition, December 2016", + "landingPage": "https://ecma-international.org/publications-and-standards/standards/ecma-376/", + "downloadUrl": "https://ecma-international.org/wp-content/uploads/ECMA-376-1_5th_edition_december_2016.zip", + "packageSha256": "9d0bcad9cf06054785b03762fcfadbf6bab7e54a5f9d69434e34b7fd464d4129" + }, + "geometryArchive": { + "outerPackageEntry": "OfficeOpenXML-DrawingMLGeometries.zip", + "repositoryPath": "scripts/ooxml-geometry/vendor/OfficeOpenXML-DrawingMLGeometries.zip", + "bytes": 51672, + "sha256": "a8517838a3a60492d267d1ff92e1ebd831adc04584de79a38a5532a68902abf6" + }, + "presetShapeDefinitions": { + "archiveEntry": "presetShapeDefinitions.xml", + "bytes": 559368, + "sha256": "2f7c868d857c1e3c4b5a6068759fe0e07d77ad58377a6618d1b02ba3507b6939" + }, + "copyright": { + "holder": "Ecma International", + "noticePath": "licenses/ECMA-text-copyright-notice.txt", + "policyUrl": "https://ecma-international.org/policies/by-ipr/ecma-text-copyright-policy/" + } +} diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/tslib/CopyrightNotice.txt b/App/frontend/desktop/public/licenses/pptx-renderer/tslib/CopyrightNotice.txt new file mode 100644 index 000000000..c18ab1d93 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/tslib/CopyrightNotice.txt @@ -0,0 +1,14 @@ +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/tslib/LICENSE.txt b/App/frontend/desktop/public/licenses/pptx-renderer/tslib/LICENSE.txt new file mode 100644 index 000000000..b72046c57 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/tslib/LICENSE.txt @@ -0,0 +1,12 @@ +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/App/frontend/desktop/public/licenses/pptx-renderer/zrender/LICENSE b/App/frontend/desktop/public/licenses/pptx-renderer/zrender/LICENSE new file mode 100644 index 000000000..66348cbf8 --- /dev/null +++ b/App/frontend/desktop/public/licenses/pptx-renderer/zrender/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2017, Baidu Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/App/frontend/desktop/scripts/sync-prototype-utilities.mjs b/App/frontend/desktop/scripts/sync-prototype-utilities.mjs new file mode 100644 index 000000000..004a4194a --- /dev/null +++ b/App/frontend/desktop/scripts/sync-prototype-utilities.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** Derive additional variants from the checked-in prototype's canonical rules. */ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath, URL } from "node:url"; +import postcss from "postcss"; + +const args = process.argv.slice(2); +const bundleArg = args.indexOf("--bundle"); +const bundlePath = bundleArg >= 0 + ? resolve(args[bundleArg + 1]) + : fileURLToPath(new URL("../src/prototype-utilities.css", import.meta.url)); +const marker = "/* Generated by scripts/sync-prototype-utilities.mjs; do not edit this layer. */"; +const current = readFileSync(bundlePath, "utf8"); +const canonical = current.split(marker)[0].trimEnd(); +const source = postcss.parse(canonical); +const layer = postcss.atRule({ name: "layer", params: "utilities" }); +const hover = postcss.atRule({ name: "media", params: "(hover:hover)" }); +const escapeClass = (token) => token.replace(/[.,:/[\]()%#]/g, (character) => `\\${character}`); + +for (const color of ["action-sky", "status-error"]) { + const baseSelector = `.${escapeClass(`border-${color}/30`)}`; + const variantSelector = `.${escapeClass(`disabled:hover:border-${color}/30`)}:disabled:hover`; + let copies = 0; + source.walkRules((rule) => { + if (rule.selector !== baseSelector) return; + // Carry the original fallback and any conditional color-mix rule together. + // The bundle remains authoritative for both the palette and browser support. + let copy = rule.clone({ selector: variantSelector }); + for (let parent = rule.parent; parent && parent.type !== "root"; parent = parent.parent) { + if (parent.type === "atrule" && parent.name === "layer") break; + if (parent.type !== "atrule") throw new Error(`Unexpected parent for ${baseSelector}`); + const wrapper = parent.clone({ nodes: [] }); + wrapper.append(copy); + copy = wrapper; + } + hover.append(copy); + copies += 1; + }); + if (!copies) throw new Error(`Canonical utility missing: ${baseSelector}`); +} +layer.append(hover); +const output = `${canonical}\n${marker}\n${layer.toString()}\n`; +if (args.includes("--check")) { + if (current !== output) throw new Error("Prototype variants are stale; run npm run utilities:sync."); +} else { + writeFileSync(bundlePath, output); +} diff --git a/App/frontend/desktop/src/analytics/page-view.ts b/App/frontend/desktop/src/analytics/page-view.ts index 0b857d687..1cb77b02e 100644 --- a/App/frontend/desktop/src/analytics/page-view.ts +++ b/App/frontend/desktop/src/analytics/page-view.ts @@ -17,6 +17,7 @@ const ROUTE_PAGE_TITLES: Record = { "/memory": "Memory", "/knowledge": "Knowledge", "/memory-sources": "Memory Sources", + "/plugin": "Plugin Surface", "/settings": "Settings" }; diff --git a/App/frontend/desktop/src/api/asr-client.ts b/App/frontend/desktop/src/api/asr-client.ts index c2df31530..513290eaa 100644 --- a/App/frontend/desktop/src/api/asr-client.ts +++ b/App/frontend/desktop/src/api/asr-client.ts @@ -6,9 +6,17 @@ import { type RuntimeConfig } from "@memmy/local-api-contracts"; import { requestJson } from "./http.js"; +import type { StreamSocket } from "../lib/asr-stream-transcription.js"; export interface AsrClient { transcribe(input: AsrTranscriptionInput): Promise; + /** + * Opens the live transcription stream. + * + * A browser WebSocket cannot carry the runtime-token header, so the token + * rides on the query string exactly as the SSE channel's does. + */ + openStream(): StreamSocket; } export function createHttpAsrClient(config: RuntimeConfig): AsrClient { @@ -20,6 +28,22 @@ export function createHttpAsrClient(config: RuntimeConfig): AsrClient { schema: AsrTranscriptionResponseSchema, body: AsrTranscriptionInputSchema.parse(input) }); + }, + openStream() { + return new WebSocket(toStreamUrl(config)); } }; } + +/** + * Builds the relay's WebSocket URL from the runtime config. + * + * @param config Runtime config carrying the local API origin and token. + * @returns The `ws://` URL with the token as a query parameter. + */ +export function toStreamUrl(config: Pick): string { + const url = new URL("/api/asr/stream", config.baseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set("token", config.localToken); + return url.toString(); +} diff --git a/App/frontend/desktop/src/api/client-types.ts b/App/frontend/desktop/src/api/client-types.ts index 26816d1eb..41e7b25a2 100644 --- a/App/frontend/desktop/src/api/client-types.ts +++ b/App/frontend/desktop/src/api/client-types.ts @@ -16,6 +16,7 @@ import { import { createHttpLocalDataClient, type LocalDataClient } from "./local-data-client.js"; import { createHttpMemoryRuntimeClient, type MemoryRuntimeClient } from "./memory-runtime-client.js"; import { createMemmyAgentClient, type MemmyAgentClient } from "./memmy-agent-client.js"; +import { createHttpPluginsClient, type PluginsClient } from "./plugins-client.js"; import { createHttpTokenQuotaClient, type TokenQuotaClient } from "./token-quota-client.js"; import { configureUserTimeZone } from "../lib/user-time-zone.js"; @@ -32,6 +33,7 @@ export interface AppClients { byokTokenUsage: ByokTokenUsageClient; asr: AsrClient; memmyAgent: MemmyAgentClient; + plugins: PluginsClient; tokenQuota: TokenQuotaClient; } @@ -58,6 +60,7 @@ export function createAppClients(input: CreateAppClientsInput): AppClients { byokTokenUsage: createHttpByokTokenUsageClient(input.runtimeConfig), asr: createHttpAsrClient(input.runtimeConfig), memmyAgent: createMemmyAgentClient(input.runtimeConfig.agentGateway), + plugins: createHttpPluginsClient(input.runtimeConfig), tokenQuota: createHttpTokenQuotaClient(input.runtimeConfig) }; } diff --git a/App/frontend/desktop/src/api/memmy-agent-client.ts b/App/frontend/desktop/src/api/memmy-agent-client.ts index cd792bcbb..a03a5b4bd 100644 --- a/App/frontend/desktop/src/api/memmy-agent-client.ts +++ b/App/frontend/desktop/src/api/memmy-agent-client.ts @@ -59,6 +59,22 @@ export type AgentGoalControlResult = { warning?: "turn_cancel_failed"; }; +export type AgentTaskPlanItemStatus = "pending" | "in_progress" | "completed" | "blocked"; +export type AgentTaskPlanStatus = "active" | "completed" | "blocked"; + +export type AgentTaskPlanState = { + plan_id: string | null; + title: string; + status: AgentTaskPlanStatus | null; + items: Array<{ + id: string; + content: string; + status: AgentTaskPlanItemStatus; + }>; + created_at: string | null; + updated_at: string | null; +}; + export type ComputerHistorySourceType = "captured" | "rollup" | "imported" | "demo_fixture"; export type ComputerHistoryReplayPlan = { @@ -149,6 +165,23 @@ const AgentGoalStateSchema = z.object({ updated_at: z.string().nullable() }).strict(); +const AgentTaskPlanStateSchema = z.object({ + plan_id: z.string().nullable(), + title: z.string(), + status: z.enum(["active", "completed", "blocked"]).nullable(), + items: z.array(z.object({ + id: z.string(), + content: z.string(), + status: z.enum(["pending", "in_progress", "completed", "blocked"]) + }).strict()), + created_at: z.string().nullable(), + updated_at: z.string().nullable() +}).strict(); + +export function isAgentTaskPlanState(value: unknown): value is AgentTaskPlanState { + return AgentTaskPlanStateSchema.safeParse(value).success; +} + export function isAgentGoalStatus(value: unknown): value is AgentGoalStatus { return value === "active" || value === "paused" @@ -345,6 +378,28 @@ const WorkspaceEnvironmentDiffSchema = z.object({ unavailable_reason: z.string().nullable() }); +const WorkspaceFileEntrySchema = z.object({ + name: z.string(), + path: z.string(), + kind: z.union([z.literal("directory"), z.literal("file")]), + size: z.number().int().nonnegative().nullable(), + modifiedAt: z.string().nullable() +}); + +const WorkspaceFilesListingSchema = z.object({ + root: z.object({ + kind: z.union([z.literal("project"), z.literal("task")]), + label: z.string() + }), + path: z.string(), + entries: z.array(WorkspaceFileEntrySchema), + truncated: z.boolean() +}); + +const WorkspaceFileActionResponseSchema = z.object({ + ok: z.literal(true) +}); + const ProjectMutationResponseSchema = z.object({ project: ProjectSchema, snapshot: SessionSnapshotSchema @@ -395,6 +450,7 @@ const WebuiThreadSchema = z.object({ sessionKey: z.string(), last_turn_id: z.string().min(1).optional(), last_turn_closed: z.boolean().optional(), + last_turn_interrupted: z.boolean().optional(), last_turn_goal_id: z.string().uuid().optional(), last_turn_goal_outcome: z.union([ z.literal("active"), @@ -440,10 +496,13 @@ const ResolvedArtifactSchema = z.object({ ok: z.literal(true), path: z.string(), name: z.string(), - kind: z.union([z.literal("image"), z.literal("video"), z.literal("file"), z.literal("directory")]), - media_url: z.string().optional() + kind: z.union([z.literal("image"), z.literal("video"), z.literal("audio"), z.literal("file"), z.literal("directory")]), + media_url: z.string().optional(), + relative_path: z.string().optional() }); +const ResolvedRecordingSchema = z.object({ media_url: z.string() }); + const RevealArtifactResponseSchema = z.object({ ok: z.literal(true), path: z.string() @@ -491,9 +550,30 @@ const UploadedAgentFileSchema = z.object({ bytes: z.number() }); -const UploadedAgentMediaSchema = z.discriminatedUnion("kind", [ +const UploadedAgentAudioMimeSchema = z.union([ + z.literal("audio/aac"), + z.literal("audio/flac"), + z.literal("audio/mp4"), + z.literal("audio/mpeg"), + z.literal("audio/ogg"), + z.literal("audio/opus"), + z.literal("audio/wav"), + z.literal("audio/webm") +]); + +const UploadedAgentAudioSchema = z.object({ + path: z.string(), + url: z.string(), + name: z.string(), + kind: z.literal("audio"), + mime: UploadedAgentAudioMimeSchema, + bytes: z.number() +}); + +export const UploadedAgentMediaSchema = z.discriminatedUnion("kind", [ UploadedAgentImageSchema, - UploadedAgentFileSchema + UploadedAgentFileSchema, + UploadedAgentAudioSchema ]); const UploadedAgentMediaResponseSchema = z.object({ @@ -510,6 +590,9 @@ export type WorkspaceEnvironmentFile = z.infer; export type WorkspaceEnvironmentDiff = z.infer; export type WorkspaceEnvironmentScope = { kind: "session" | "project"; key: string }; +export type WorkspaceFilesScope = WorkspaceEnvironmentScope; +export type WorkspaceFileEntry = z.infer; +export type WorkspaceFilesListing = z.infer; export type MemmyAgentProject = z.infer; export type MemmyAgentSessionSnapshot = z.infer; export type MemmyAgentSidebarState = z.infer; @@ -578,11 +661,13 @@ export type UploadAgentMediaInput = { name: string; kind: UploadedAgentMedia["kind"]; mime: UploadedAgentMedia["mime"]; + /** `recordings` keeps the file in the default workspace instead of the media cache. */ + target?: "recordings"; }; -export type MemmyAgentMediaInput = UploadedAgentMedia; +export type MemmyAgentMediaInput = UploadedAgentMedia | (MemmyAgentMediaAttachment & { path: string; kind: MemmyAgentMediaKind; name: string }); -export type MemmyAgentMediaKind = "image" | "video" | "file"; +export type MemmyAgentMediaKind = "image" | "video" | "audio" | "file"; export type MemmyAgentUiLanguage = "zh-CN" | "en-US"; export type MemmyAgentMediaAttachment = { @@ -617,6 +702,7 @@ export type WebuiQueuedMessage = { queue_surface?: "chat_composer" | null; turn_admission?: "steer"; turn_id?: string; + user_event_on_consume?: boolean; }; export type MemmyAgentMessageSubmissionResult = { @@ -648,6 +734,17 @@ export type MemmyAgentSendMessageInput = { modelPreset?: string | null; thinkingEnabled?: boolean; thinkingLevel?: string | null; + interruptPendingTurnId?: string; +}; + +export type MemmyAgentQuestionResponseInput = { + requestId: string; + cancelled?: boolean; + answers: Array<{ + questionId: string; + selectedOptionIds: string[]; + otherText?: string; + }>; }; export interface MemmyAgentNewChatResult { @@ -670,6 +767,8 @@ export type MemmyAgentModelError = { export type MemmyAgentWsEvent = { event: string; + phase?: string; + tool_name?: string; replayed?: boolean; connection_generation?: number; chat_id?: string; @@ -693,6 +792,7 @@ export type MemmyAgentWsEvent = { agent_ui?: unknown; edits?: unknown; goal_state?: AgentGoalState; + task_plan_state?: AgentTaskPlanState; goal_id?: string; goal_outcome?: AgentGoalStatus; compaction_id?: string; @@ -793,6 +893,10 @@ export interface MemmyAgentClient { listSessions(): Promise; readWorkspaceEnvironment(scope: WorkspaceEnvironmentScope): Promise; readWorkspaceEnvironmentDiff(scope: WorkspaceEnvironmentScope, path: string): Promise; + listWorkspaceFiles(scope: WorkspaceFilesScope | string, path?: string): Promise; + readWorkspaceFile(scope: WorkspaceFilesScope | string, path: string, signal?: AbortSignal): Promise; + openWorkspaceFile(scope: WorkspaceFilesScope | string, path: string): Promise; + revealWorkspaceFile(scope: WorkspaceFilesScope | string, path: string): Promise; switchWorkspaceEnvironmentBranch( scope: WorkspaceEnvironmentScope, branch: string, @@ -834,9 +938,11 @@ export interface MemmyAgentClient { renameSession(sessionKey: string, title: string): Promise; deleteSession(sessionKey: string): Promise; resolveArtifact(path: string, sessionKey: string): Promise; + resolveRecording(path: string): Promise; revealArtifact(path: string, sessionKey: string): Promise; openArtifact(path: string, sessionKey: string): Promise; uploadAgentMedia(attachments: UploadAgentMediaInput[]): Promise; + deleteRecordingFiles(paths: string[]): Promise; uploadAgentImages(images: UploadAgentImageInput[]): Promise; connectWebSocket(onEvent?: (event: MemmyAgentWsEvent) => void): Promise; sessionKeyToChatId(sessionKey: string): string; @@ -859,12 +965,19 @@ export interface MemmyAgentWebSocketConnection { input: MemmyAgentSendMessageInput, expectedGeneration: number ): Promise; + respondToQuestion( + chatId: string, + response: MemmyAgentQuestionResponseInput, + expectedGeneration: number, + timeoutMs?: number + ): Promise; removeQueuedMessage( chatId: string, clientRequestId: string, expectedGeneration: number, timeoutMs?: number ): Promise; + reorderQueuedMessages(chatId: string, orderedIds: string[], expectedRevision: number, expectedGeneration: number): void; steerQueuedMessage( chatId: string, clientRequestId: string, @@ -893,6 +1006,7 @@ export interface MemmyAgentWebSocketConnection { requestRunStatusSnapshot(chatId: string, expectedGeneration: number, timeoutMs?: number): Promise; getRunStartedAt(chatId: string): number | null; getGoalState(chatId: string): AgentGoalState | undefined; + getTaskPlanState(chatId: string): AgentTaskPlanState | undefined; close(): void; } @@ -941,13 +1055,13 @@ export interface WebSocketLike { export class MemmyAgentRequestError extends Error { readonly status: number; readonly code: string | null; - readonly data?: { sidebarState: MemmyAgentSidebarState }; + readonly data?: { sidebarState?: MemmyAgentSidebarState; worktreePath?: string }; constructor( message: string, status: number, code: string | null = null, - data?: { sidebarState: MemmyAgentSidebarState } + data?: { sidebarState?: MemmyAgentSidebarState; worktreePath?: string } ) { super(message); this.name = "MemmyAgentRequestError"; @@ -1317,6 +1431,38 @@ class HttpMemmyAgentClient implements MemmyAgentClient { ); } + async listWorkspaceFiles(scope: WorkspaceFilesScope | string, path = ""): Promise { + const normalizedScope = typeof scope === "string" ? { kind: "session" as const, key: scope } : scope; + const collection = normalizedScope.kind === "session" ? "sessions" : "projects"; + const query = path ? `?${new URLSearchParams({ path }).toString()}` : ""; + return this.request( + `/api/${collection}/${encodeURIComponent(normalizedScope.key)}/workspace/files${query}`, + WorkspaceFilesListingSchema + ); + } + + async readWorkspaceFile( + scope: WorkspaceFilesScope | string, + path: string, + signal?: AbortSignal + ): Promise { + const normalizedScope = typeof scope === "string" ? { kind: "session" as const, key: scope } : scope; + const collection = normalizedScope.kind === "session" ? "sessions" : "projects"; + const query = new URLSearchParams({ path }); + return this.requestBlob( + `/api/${collection}/${encodeURIComponent(normalizedScope.key)}/workspace/file?${query.toString()}`, + signal + ); + } + + async openWorkspaceFile(scope: WorkspaceFilesScope | string, path: string): Promise { + await this.workspaceFileAction(scope, path, "open"); + } + + async revealWorkspaceFile(scope: WorkspaceFilesScope | string, path: string): Promise { + await this.workspaceFileAction(scope, path, "reveal"); + } + async switchWorkspaceEnvironmentBranch( scope: WorkspaceEnvironmentScope, branch: string, @@ -1458,6 +1604,14 @@ class HttpMemmyAgentClient implements MemmyAgentClient { }); } + async resolveRecording(path: string): Promise { + const result = await this.request("/api/webui/recordings/resolve", ResolvedRecordingSchema, { + method: "POST", + body: { path } + }); + return new URL(result.media_url, this.baseUrl).toString(); + } + async revealArtifact(path: string, sessionKey: string): Promise { await this.request("/api/webui/artifacts/reveal", RevealArtifactResponseSchema, { method: "POST", @@ -1481,6 +1635,7 @@ class HttpMemmyAgentClient implements MemmyAgentClient { const form = new FormData(); for (const attachment of attachments) { form.append("files", blobWithUploadMime(attachment.blob, attachment.mime), uploadFilenameForMedia(attachment.name, attachment.mime, attachment.kind)); + form.append("targets", attachment.target ?? ""); } return form; }; @@ -1510,6 +1665,13 @@ class HttpMemmyAgentClient implements MemmyAgentClient { return normalizeGatewayMediaUrls(parsed.attachments ?? parsed.images ?? [], this.baseUrl); } + async deleteRecordingFiles(paths: string[]): Promise { + await this.request("/api/webui/recordings/delete", z.object({ ok: z.literal(true) }), { + method: "POST", + body: { paths } + }); + } + async uploadAgentImages(images: UploadAgentImageInput[]): Promise { const media = await this.uploadAgentMedia(images.map((image) => ({ ...image, kind: "image" }))); return media.filter((item): item is UploadedAgentImage => item.kind === "image"); @@ -1590,6 +1752,41 @@ class HttpMemmyAgentClient implements MemmyAgentClient { if (timeout) clearTimeout(timeout); } } + + private async requestBlob(path: string, signal?: AbortSignal): Promise { + const send = (boot: MemmyAgentBootstrap) => this.fetchFn(new URL(path, this.baseUrl), { + headers: { Authorization: `Bearer ${boot.token}` }, + ...(signal ? { signal } : {}) + }); + let boot = await this.bootstrap(); + let response = await send(boot); + if (response.status === 401) { + this.boot = null; + this.bootExpiresAtMs = 0; + boot = await this.bootstrap({ force: true }); + response = await send(boot); + } + if (!response.ok) { + const parsed = await parseRequestError(response); + throw new MemmyAgentRequestError(parsed.message, response.status, parsed.code, parsed.data); + } + return response.blob(); + } + + private async workspaceFileAction( + scope: WorkspaceFilesScope | string, + path: string, + action: "open" | "reveal" + ): Promise { + const normalizedScope = typeof scope === "string" ? { kind: "session" as const, key: scope } : scope; + const collection = normalizedScope.kind === "session" ? "sessions" : "projects"; + const query = new URLSearchParams({ path }); + await this.request( + `/api/${collection}/${encodeURIComponent(normalizedScope.key)}/workspace/file/${action}?${query.toString()}`, + WorkspaceFileActionResponseSchema, + { method: "POST" } + ); + } } const WS_OPEN = 1; @@ -1602,6 +1799,7 @@ const GOAL_CONTROL_TIMEOUT_MS = 15_000; const GOAL_CONTROL_HYDRATE_TIMEOUT_MS = 5_000; const QUEUE_REMOVE_TIMEOUT_MS = 15_000; const QUEUE_STEER_TIMEOUT_MS = 15_000; +const QUESTION_RESPONSE_TIMEOUT_MS = 15_000; interface MemmyAgentWebSocketSessionInput { bootstrap(options?: { force?: boolean }): Promise; @@ -1635,6 +1833,13 @@ interface PendingGoalControl { calibrating: boolean; } +interface PendingQuestionResponse { + chatId: string; + resolve: () => void; + reject: (error: Error) => void; + timer: ReturnType; +} + interface PendingMessageAttempt { input: MemmyAgentSendMessageInput & { clientRequestId: string }; queueSurface: "chat_composer" | null; @@ -1686,6 +1891,7 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { private readonly pendingQueueRemovals = new Map(); private readonly pendingQueueSteers = new Map(); private readonly pendingGoalControls = new Map(); + private readonly pendingQuestionResponses = new Map(); private connectionGeneration = 0; private transportOpenGeneration: number | null = null; private readyGeneration: number | null = null; @@ -1704,6 +1910,7 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { private readonly runLifecycleHandlers = new Set<(chatId: string, event: MemmyAgentRunLifecycleEvent) => void>(); private readonly runStartedAtByChatId = new Map(); private readonly goalStateByChatId = new Map(); + private readonly taskPlanStateByChatId = new Map(); constructor(private readonly input: MemmyAgentWebSocketSessionInput) {} @@ -1828,6 +2035,50 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { ).firstPromise; } + respondToQuestion( + chatId: string, + response: MemmyAgentQuestionResponseInput, + expectedGeneration: number, + timeoutMs = QUESTION_RESPONSE_TIMEOUT_MS + ): Promise { + this.assertReadyGeneration(expectedGeneration); + const key = messageAttemptKey(chatId, response.requestId); + if (this.pendingQuestionResponses.has(key)) { + return Promise.reject(new Error("Question response is already pending")); + } + return new Promise((resolve, reject) => { + const pending: PendingQuestionResponse = { + chatId, + resolve, + reject, + timer: setTimeout(() => { + if (this.pendingQuestionResponses.get(key) === pending) { + this.pendingQuestionResponses.delete(key); + } + reject(new Error("Question response timed out")); + }, timeoutMs) + }; + this.pendingQuestionResponses.set(key, pending); + try { + this.sendOrdinaryFrame({ + type: "agent_question_response", + chat_id: chatId, + request_id: response.requestId, + ...(response.cancelled === true ? { cancelled: true } : {}), + answers: response.answers.map((answer) => ({ + question_id: answer.questionId, + selected_option_ids: answer.selectedOptionIds, + ...(answer.otherText ? { other_text: answer.otherText } : {}) + })) + }, expectedGeneration); + } catch (error) { + this.pendingQuestionResponses.delete(key); + clearTimeout(pending.timer); + reject(asError(error, "Unable to answer question")); + } + }); + } + removeQueuedMessage( chatId: string, clientRequestId: string, @@ -1865,6 +2116,21 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { }); } + reorderQueuedMessages( + chatId: string, + orderedIds: string[], + expectedRevision: number, + expectedGeneration: number, + ): void { + this.sendOrdinaryFrame({ + type: "queue_reorder", + chat_id: chatId, + request_id: crypto.randomUUID(), + ordered_ids: orderedIds, + expected_revision: expectedRevision, + }, expectedGeneration); + } + steerQueuedMessage( chatId: string, clientRequestId: string, @@ -2162,6 +2428,10 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { return this.goalStateByChatId.get(chatId); } + getTaskPlanState(chatId: string): AgentTaskPlanState | undefined { + return this.taskPlanStateByChatId.get(chatId); + } + requestRunStatusSnapshot( chatId: string, expectedGeneration: number, @@ -2208,6 +2478,7 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { this.rejectPendingQueueRemovals(new Error("queue removal cancelled")); this.rejectPendingQueueSteers(new Error("queue steer cancelled")); this.rejectPendingGoalControls(new Error("Goal control cancelled")); + this.rejectPendingQuestionResponses(new Error("Question response cancelled")); this.rejectInitialReady(new Error("Agent gateway connection cancelled")); this.clearReadyHandshakeTimer(); if (this.reconnectTimer) { @@ -2309,6 +2580,8 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { this.resolvePendingQueueRemoval(normalized); } else if (normalized.event === "queue_steer_result") { this.resolvePendingQueueSteer(normalized); + } else if (normalized.event === "agent_question_response_result") { + this.resolvePendingQuestionResponse(normalized); } if (normalized.event === "attached") { @@ -2373,6 +2646,7 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { this.recordRunStatus(chatId, normalized); this.recordGoalState(chatId, normalized); + this.recordTaskPlanState(chatId, normalized); this.resolveRunStatusSnapshot(chatId, normalized, generation); this.dispatchChat(chatId, normalized); } @@ -2400,6 +2674,7 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { this.rejectPendingRunStatusSnapshots(new Error("run status snapshot failed because websocket closed"), generation); this.rejectPendingQueueRemovals(new Error("queue removal failed because websocket closed")); this.rejectPendingQueueSteers(new Error("queue steer failed because websocket closed")); + this.rejectPendingQuestionResponses(new Error("question response failed because websocket closed")); if (this.intentionallyClosed) { return; } @@ -2502,6 +2777,7 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { ...(input.modelPreset !== undefined ? { model_preset: input.modelPreset } : {}), ...(input.thinkingEnabled !== undefined ? { thinking_enabled: input.thinkingEnabled } : {}), ...(input.thinkingLevel ? { thinking_level: input.thinkingLevel } : {}), + ...(input.interruptPendingTurnId ? { interrupt_pending_turn_id: input.interruptPendingTurnId } : {}), ...(input.media?.length ? { media_paths: input.media.map((item) => item.path) } : {}) }, expectedGeneration); this.knownChats.add(input.chatId); @@ -2847,6 +3123,13 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { } } + private recordTaskPlanState(chatId: string, event: MemmyAgentWsEvent): void { + if (event.event !== "task_plan_state") return; + const parsed = AgentTaskPlanStateSchema.safeParse(event.task_plan_state); + if (!parsed.success) return; + this.taskPlanStateByChatId.set(chatId, parsed.data); + } + private resolvePendingGoalControl(event: MemmyAgentWsEvent): void { const chatId = event.chat_id; const requestId = typeof event.request_id === "string" ? event.request_id : null; @@ -2869,6 +3152,27 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { )); } + private resolvePendingQuestionResponse(event: MemmyAgentWsEvent): void { + const chatId = event.chat_id; + const requestId = typeof event.request_id === "string" ? event.request_id : null; + if (!chatId || !requestId) return; + const key = messageAttemptKey(chatId, requestId); + const pending = this.pendingQuestionResponses.get(key); + if (!pending) return; + this.pendingQuestionResponses.delete(key); + clearTimeout(pending.timer); + if (event.ok === true) pending.resolve(); + else pending.reject(new Error(typeof event.error === "string" ? event.error : "Unable to answer question")); + } + + private rejectPendingQuestionResponses(error: Error): void { + for (const [key, pending] of this.pendingQuestionResponses) { + this.pendingQuestionResponses.delete(key); + clearTimeout(pending.timer); + pending.reject(error); + } + } + private beginGoalControlCalibration(key: string, pending: PendingGoalControl): void { if (this.pendingGoalControls.get(key) !== pending) return; if (pending.timer) clearTimeout(pending.timer); @@ -3156,7 +3460,7 @@ const AGENT_UPLOAD_UNSAFE_FILENAME_CHARS = new RegExp( function uploadFilenameForMedia(name: string, mime: UploadedAgentMedia["mime"], kind: UploadedAgentMedia["kind"]): string { const fallback = kind === "image" ? "image" : "attachment"; const base = uploadFilenameBase(name, fallback); - if (kind === "file") { + if (kind === "file" || kind === "audio") { return base; } @@ -3188,7 +3492,7 @@ async function errorMessage(response: Response): Promise { async function parseRequestError(response: Response): Promise<{ message: string; code: string | null; - data?: { sidebarState: MemmyAgentSidebarState }; + data?: { sidebarState?: MemmyAgentSidebarState; worktreePath?: string }; }> { try { const body = await response.json(); @@ -3206,6 +3510,9 @@ async function parseRequestError(response: Response): Promise<{ return { message, code, data: { sidebarState: sidebarState.data } }; } } + if (code === "workspace_branch_in_use" && typeof record.worktreePath === "string") { + return { message, code, data: { worktreePath: record.worktreePath } }; + } return { message, code }; } } catch { diff --git a/App/frontend/desktop/src/api/plugins-client.ts b/App/frontend/desktop/src/api/plugins-client.ts new file mode 100644 index 000000000..29ec2ee70 --- /dev/null +++ b/App/frontend/desktop/src/api/plugins-client.ts @@ -0,0 +1,156 @@ +import { notifyPluginChange } from "../lib/plugin-change-event.js"; +import { + InstalledPluginSchema, + PluginCatalogSchema, + type PluginCatalogEntry, + InstalledPluginsSchema, + InvokePluginCapabilityInputSchema, + InvokePluginCapabilityResponseSchema, + OkResponseSchema, + PluginInteractionResponseInputSchema, + PluginUiRendererResponseSchema, + type InstalledPlugin, + type PluginPermission, + type InvokePluginCapabilityInput, + type InvokePluginCapabilityResponse, + type PluginUiSlot, + type RuntimeConfig +} from "@memmy/local-api-contracts"; +import { requestJson } from "./http.js"; +import { userTimeZone } from "../lib/user-time-zone.js"; + +export interface PluginsClient { + list(): Promise; + catalog?(): Promise; + install(pluginId: string, version?: string): Promise; + update(pluginId: string, version: string): Promise; + approvePermissions(pluginId: string, permissions: PluginPermission[]): Promise; + enable(pluginId: string): Promise; + disable(pluginId: string): Promise; + uninstall(pluginId: string): Promise; + getUi(pluginId: string, slot: PluginUiSlot): Promise; + /** Reads a Host-validated plugin artifact with the local runtime credential. */ + readArtifact(uri: string): Promise; + invoke(pluginId: string, capabilityId: string, input: InvokePluginCapabilityInput): Promise; + cancel(pluginId: string, callId: string): Promise; + respond(pluginId: string, callId: string, interactionId: string, response: unknown): Promise; +} + +export const pluginEndpointPaths = { + list: "/api/v1/plugins", + install: "/api/v1/plugins/install", + update: (pluginId: string) => `/api/v1/plugins/${encodeURIComponent(pluginId)}/update`, + permissions: (pluginId: string) => `/api/v1/plugins/${encodeURIComponent(pluginId)}/permissions`, + enable: (pluginId: string) => `/api/v1/plugins/${encodeURIComponent(pluginId)}/enable`, + disable: (pluginId: string) => `/api/v1/plugins/${encodeURIComponent(pluginId)}/disable`, + plugin: (pluginId: string) => `/api/v1/plugins/${encodeURIComponent(pluginId)}`, + ui: (pluginId: string, slot: PluginUiSlot) => `/api/v1/plugins/${encodeURIComponent(pluginId)}/ui/${slot}`, + invoke: (pluginId: string, capabilityId: string) => ( + `/api/v1/plugins/${encodeURIComponent(pluginId)}/capabilities/${encodeURIComponent(capabilityId)}/invoke` + ), + cancel: (pluginId: string, callId: string) => ( + `/api/v1/plugins/${encodeURIComponent(pluginId)}/calls/${encodeURIComponent(callId)}/cancel` + ), + respond: (pluginId: string, callId: string, interactionId: string) => ( + `/api/v1/plugins/${encodeURIComponent(pluginId)}/calls/${encodeURIComponent(callId)}/interactions/${encodeURIComponent(interactionId)}` + ) +}; + +export function createHttpPluginsClient(config: RuntimeConfig): PluginsClient { + const uiCache = new Map>(); + return { + catalog() { return requestJson({ config, path: "/api/v1/plugins/catalog", schema: PluginCatalogSchema }); }, + list() { + return requestJson({ config, path: pluginEndpointPaths.list, schema: InstalledPluginsSchema }); + }, + install(pluginId, version) { + return requestJson({ + config, + path: pluginEndpointPaths.install, + schema: InstalledPluginSchema, + body: { pluginId, ...(version ? { version } : {}) } + }); + }, + async update(pluginId, version) { + const updated = await requestJson({ config, path: pluginEndpointPaths.update(pluginId), schema: InstalledPluginSchema, body: { version } }); + for (const key of uiCache.keys()) { + if (key.startsWith(`${pluginId}:`)) uiCache.delete(key); + } + notifyPluginChange(); + return updated; + }, + approvePermissions(pluginId, permissions) { + return requestJson({ + config, + path: pluginEndpointPaths.permissions(pluginId), + init: { method: "PUT" }, + schema: InstalledPluginSchema, + body: { permissions } + }); + }, + enable(pluginId) { + return requestJson({ config, path: pluginEndpointPaths.enable(pluginId), schema: InstalledPluginSchema, body: {} }); + }, + disable(pluginId) { + return requestJson({ config, path: pluginEndpointPaths.disable(pluginId), schema: InstalledPluginSchema, body: {} }); + }, + async uninstall(pluginId) { + await requestJson({ config, path: pluginEndpointPaths.plugin(pluginId), init: { method: "DELETE" }, schema: OkResponseSchema }); + notifyPluginChange(); + }, + getUi(pluginId, slot) { + const cacheKey = `${pluginId}:${slot}`; + const cached = uiCache.get(cacheKey); + if (cached) return cached; + const request = requestJson({ + config, + path: pluginEndpointPaths.ui(pluginId, slot), + schema: PluginUiRendererResponseSchema + }).then(({ html }) => html).catch((error) => { + uiCache.delete(cacheKey); + throw error; + }); + uiCache.set(cacheKey, request); + return request; + }, + async readArtifact(uri) { + const base = new URL(config.baseUrl); + const target = new URL(uri, base); + if (target.origin !== base.origin || !/^\/api\/v1\/plugins\/[^/]+\/artifacts\/[^/]+\/(?:preview|download)$/u.test(target.pathname)) { + throw new Error("Refusing to read an untrusted plugin artifact URI"); + } + const response = await fetch(target, { + headers: { + "x-memmy-local-token": config.localToken, + "x-memmy-time-zone": userTimeZone(config.timeZone) + } + }); + if (!response.ok) throw new Error(`Plugin artifact request failed with status ${response.status}`); + return response.blob(); + }, + invoke(pluginId, capabilityId, input) { + return requestJson({ + config, + path: pluginEndpointPaths.invoke(pluginId, capabilityId), + schema: InvokePluginCapabilityResponseSchema, + body: InvokePluginCapabilityInputSchema.parse(input) + }); + }, + async cancel(pluginId, callId) { + await requestJson({ + config, + path: pluginEndpointPaths.cancel(pluginId, callId), + schema: OkResponseSchema, + body: {} + }); + }, + async respond(pluginId, callId, interactionId, response) { + await requestJson({ + config, + path: pluginEndpointPaths.respond(pluginId, callId, interactionId), + schema: OkResponseSchema, + body: PluginInteractionResponseInputSchema.parse({ response }) + }); + } + }; +} diff --git a/App/frontend/desktop/src/api/tests/http.test.ts b/App/frontend/desktop/src/api/tests/http.test.ts index ade8a1810..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(); }); @@ -121,4 +130,5 @@ describe("requestJson", () => { }) ); }); + }); diff --git a/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts b/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts index 9c0e2a637..219a0a70b 100644 --- a/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts +++ b/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts @@ -752,6 +752,20 @@ describe("memmy-agent client", () => { ]); }); + it("renews recording playback without a chat session", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + if (url.pathname === "/webui/bootstrap") return json(bootstrap); + expect(url.pathname).toBe("/api/webui/recordings/resolve"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ path: "/workspace/recordings/interview.webm" }); + return json({ media_url: "/api/media/ws/signed/audio" }); + }); + const client = createMemmyAgentClient({ baseUrl: "http://127.0.0.1:18980", clientId: "frontend-test", fetchFn: fetchMock as typeof fetch }); + await expect(client.resolveRecording("/workspace/recordings/interview.webm")) + .resolves.toBe("http://127.0.0.1:18980/api/media/ws/signed/audio"); + }); + it("uploads agent attachments as multipart and normalizes returned signed URLs", async () => { const calls: Array<{ path: string; init?: RequestInit }> = []; const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { @@ -2135,6 +2149,27 @@ describe("memmy-agent client", () => { await expect(removal).resolves.toEqual({ outcome: "already_dequeued", revision: 8 }); }); + it("sends the full queue order with the current revision", async () => { + const sockets: FakeSocket[] = []; + const client = createMemmyAgentClient({ + baseUrl: "https://agent.local:18980", + clientId: "frontend-test", + fetchFn: vi.fn(async () => json(bootstrap)) as typeof fetch, + webSocketFactory: (url) => { + const socket = new FakeSocket(url); + sockets.push(socket); + return socket; + } + }); + const connection = await connectReady(client, sockets); + const orderedIds = ["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222"]; + connection.reorderQueuedMessages("chat-reorder", orderedIds, 7, 1); + expect(JSON.parse(sockets[0]!.sent.at(-1)!)).toMatchObject({ + type: "queue_reorder", chat_id: "chat-reorder", ordered_ids: orderedIds, + expected_revision: 7, request_id: expect.any(String), + }); + }); + it("sends one queue-steer control and terminates the original queued attempt", async () => { const sockets: FakeSocket[] = []; const client = createMemmyAgentClient({ diff --git a/App/frontend/desktop/src/api/tests/plugins-client.test.ts b/App/frontend/desktop/src/api/tests/plugins-client.test.ts new file mode 100644 index 000000000..2fca53061 --- /dev/null +++ b/App/frontend/desktop/src/api/tests/plugins-client.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createHttpPluginsClient } from "../plugins-client.js"; + +const config = { baseUrl: "http://127.0.0.1:18100", localToken: "token", timeZone: "+00:00" }; + +afterEach(() => vi.unstubAllGlobals()); + +describe("plugins client", () => { + it("loads UI slots independently and invokes declared capabilities", async () => { + const fetchMock = vi.fn(async (request: URL, init?: RequestInit) => { + if (request.pathname.endsWith("/invoke")) { + expect(init?.method).toBe("POST"); + // The client parses through the contract, so a caller that says nothing + // about origin sends the Agent default rather than omitting the field. + expect(JSON.parse(String(init?.body))).toEqual({ conversationId: "chat-1", input: { topic: "memory" }, origin: "agent" }); + return Response.json({ callId: "call-1", event: { type: "result", output: { ok: true } } }); + } + return Response.json({ html: request.pathname.endsWith("/surface") ? "surface" : "renderer" }); + }); + vi.stubGlobal("fetch", fetchMock); + const client = createHttpPluginsClient(config); + + await expect(client.getUi("com.example.review", "renderer")).resolves.toBe("renderer"); + await expect(client.getUi("com.example.review", "surface")).resolves.toBe("surface"); + await expect(client.invoke("com.example.review", "run", { conversationId: "chat-1", input: { topic: "memory" } })).resolves.toMatchObject({ callId: "call-1" }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("manages plugin installation, permission approval, and lifecycle", async () => { + const plugin = installedPlugin(); + const calls: Array<{ path: string; method: string; body: unknown }> = []; + vi.stubGlobal("fetch", vi.fn(async (request: URL, init?: RequestInit) => { + calls.push({ + path: request.pathname, + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(String(init.body)) : undefined + }); + if (init?.method === "DELETE") return Response.json({ ok: true }); + return Response.json(plugin); + })); + const client = createHttpPluginsClient(config); + + await client.install(plugin.id); + await client.update(plugin.id, "0.2.3"); + await client.approvePermissions(plugin.id, plugin.manifest.permissions); + await client.enable(plugin.id); + await client.disable(plugin.id); + await client.uninstall(plugin.id); + + expect(calls).toEqual([ + { path: "/api/v1/plugins/install", method: "POST", body: { pluginId: plugin.id } }, + { path: `/api/v1/plugins/${plugin.id}/update`, method: "POST", body: { version: "0.2.3" } }, + { path: `/api/v1/plugins/${plugin.id}/permissions`, method: "PUT", body: { permissions: plugin.manifest.permissions } }, + { path: `/api/v1/plugins/${plugin.id}/enable`, method: "POST", body: {} }, + { path: `/api/v1/plugins/${plugin.id}/disable`, method: "POST", body: {} }, + { path: `/api/v1/plugins/${plugin.id}`, method: "DELETE", body: undefined } + ]); + }); + + it("reads Host-managed artifacts with local authentication and rejects foreign URLs", async () => { + const fetchMock = vi.fn(async (_request: URL, init?: RequestInit) => { + expect(init?.headers).toMatchObject({ "x-memmy-local-token": "token" }); + return new Response("%PDF", { headers: { "content-type": "application/pdf" } }); + }); + vi.stubGlobal("fetch", fetchMock); + const client = createHttpPluginsClient(config); + + const blob = await client.readArtifact("/api/v1/plugins/literature-review/artifacts/token/preview"); + expect(await blob.text()).toBe("%PDF"); + await expect(client.readArtifact("https://example.test/review.pdf")).rejects.toThrow("untrusted"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reloads plugin UI slots after a successful update", async () => { + let version = "old"; + const fetchMock = vi.fn(async (request: URL) => { + if (request.pathname.endsWith("/update")) { version = "new"; return Response.json(installedPlugin()); } + return Response.json({ html: version }); + }); + vi.stubGlobal("fetch", fetchMock); + const client = createHttpPluginsClient(config); + await expect(client.getUi("literature-review", "renderer")).resolves.toBe("old"); + await expect(client.getUi("literature-review", "renderer")).resolves.toBe("old"); + await client.update("literature-review", "0.2.3"); + await expect(client.getUi("literature-review", "renderer")).resolves.toBe("new"); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); + +function installedPlugin() { + return { + id: "literature-review", + version: "0.2.0", + manifest: { + apiVersion: "memmy/v1", + id: "literature-review", + name: "Literature Review", + version: "0.2.0", + runtime: { adapter: "command", config: { command: "runtime/command.js" } }, + capabilities: [{ id: "run", name: "Run", description: "Run", inputSchema: { type: "object" }, outputSchema: { type: "object" }, execution: "job" }], + permissions: [{ type: "network", hosts: ["export.arxiv.org", "arxiv.org"] }] + }, + state: "pending_approval", + approvedPermissions: [], + config: {}, + lastError: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z" + }; +} diff --git a/App/frontend/desktop/src/app.tsx b/App/frontend/desktop/src/app.tsx index f62233178..acb24dafb 100644 --- a/App/frontend/desktop/src/app.tsx +++ b/App/frontend/desktop/src/app.tsx @@ -15,6 +15,7 @@ import { type AgentTaskStateCoordinator } from "./app/agent-runtime-bridge.js"; import { AppProviders, useApiClients } from "./app/providers.js"; +import { usePluginUi } from "./app/plugin-ui-context.js"; import { AppRouter } from "./app/router.js"; import { UpdateCoordinatorProvider } from "./app/update-coordinator.js"; import { CampaignPromptHost } from "./components/campaign-prompt-host.js"; @@ -75,6 +76,7 @@ export function App() { function RuntimeApp() { const { state, dispatch } = useAppState(); const { clients, setClients } = useApiClients(); + const { receive: receivePluginEvent, reconcilePendingCalls } = usePluginUi(); useComputerHistoryTrayIndicator(clients?.memmyAgent ?? null); useComputerHistoryModelSync({ client: clients?.memmyAgent ?? null, @@ -336,6 +338,14 @@ function RuntimeApp() { )) ); }); + events.addEventListener("plugin.capability_event", (event) => { + const parsed = parseSseEvent(event); + if (parsed?.type === "plugin.capability_event") receivePluginEvent(parsed.payload); + }); + events.addEventListener("plugin.capability_snapshot", (event) => { + const parsed = parseSseEvent(event); + if (parsed?.type === "plugin.capability_snapshot") reconcilePendingCalls(parsed.payload.calls); + }); events.onerror = () => dispatch(appActions.eventStatusChanged("reconnecting")); } catch (error) { if (!isActive) { @@ -355,7 +365,7 @@ function RuntimeApp() { clearTimeout(timeout); } }; - }, [bootKey, dispatch, setClients]); + }, [bootKey, dispatch, receivePluginEvent, reconcilePendingCalls, setClients]); return ( diff --git a/App/frontend/desktop/src/app/agent-runtime-bridge.tsx b/App/frontend/desktop/src/app/agent-runtime-bridge.tsx index 430b5ae44..f9642ee24 100644 --- a/App/frontend/desktop/src/app/agent-runtime-bridge.tsx +++ b/App/frontend/desktop/src/app/agent-runtime-bridge.tsx @@ -586,7 +586,8 @@ export function isAgentRuntimeBridgeRoute(path: AppRoutePath): boolean { || path === "/tools" || path === "/settings" || path === "/memory" - || path === "/memory-sources"; + || path === "/memory-sources" + || path === "/plugin"; } /** Confirm reducer consumption, independently of whether a notification is shown. */ diff --git a/App/frontend/desktop/src/app/error-boundary.tsx b/App/frontend/desktop/src/app/error-boundary.tsx new file mode 100644 index 000000000..c2b93e2e8 --- /dev/null +++ b/App/frontend/desktop/src/app/error-boundary.tsx @@ -0,0 +1,202 @@ +/** + * Last line of defence for a renderer that throws. + * + * When a render or lifecycle error escapes React, React unmounts the entire + * tree. Nothing in this app caught one, so the window kept its chrome and lost + * every pixel of content — the blank page a user cannot report and cannot + * recover from. This boundary sits above the providers (so a provider that + * throws is caught too) and replaces that blank page with the error itself, a + * retry that remounts the subtree, and a reload. + * + * It deliberately avoids `useTranslation` and the app's CSS classes: it renders + * precisely when the providers above it may have failed, so it reads the + * message catalog directly, picks a language from the OS, and carries its own + * styles. A boundary that depends on the tree it is protecting is not a + * boundary. + */ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { formatMessage, messageCatalogs, type ResolvedLanguage } from "../i18n/messages.js"; + +export interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; + language: ResolvedLanguage; + /** Bumped per retry so the subtree remounts instead of resuming broken state. */ + attempt: number; + showDetails: boolean; +} + +/** Picks a language without the i18n provider, which may be the thing that broke. */ +function preferredLanguage(): ResolvedLanguage { + if (typeof navigator === "undefined") return "zh-CN"; + const languages = navigator.languages?.length ? navigator.languages : [navigator.language]; + return languages.some((value) => value?.toLowerCase().startsWith("zh")) ? "zh-CN" : "en-US"; +} + +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { + error: null, + language: preferredLanguage(), + attempt: 0, + showDetails: false + }; + + static getDerivedStateFromError(error: Error): Partial { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // main.tsx routes console through electron-log, so this lands in the log + // file the user can hand over. The component stack is the part that says + // which subtree threw, and React only gives it to this hook. + console.error("Renderer error boundary caught:", error, info.componentStack); + } + + private readonly retry = (): void => { + this.setState((current) => ({ error: null, attempt: current.attempt + 1, showDetails: false })); + }; + + private readonly reload = (): void => { + window.location.reload(); + }; + + private readonly toggleLanguage = (): void => { + this.setState((current) => ({ language: current.language === "zh-CN" ? "en-US" : "zh-CN" })); + }; + + private readonly toggleDetails = (): void => { + this.setState((current) => ({ showDetails: !current.showDetails })); + }; + + private t(key: "app.crash.title" | "app.crash.description" | "app.crash.retry" | "app.crash.reload" | "app.crash.details" | "app.crash.switchLanguage"): string { + return formatMessage(messageCatalogs[this.state.language][key]); + } + + render(): ReactNode { + const { error } = this.state; + if (!error) { + // Remounting on `attempt` gives a retry a genuinely fresh subtree rather + // than the one that just threw, with its broken state still in place. + return {this.props.children}; + } + + return ( +
+
+

{this.t("app.crash.title")}

+

{this.t("app.crash.description")}

+
+ + + +
+ + {this.state.showDetails ? ( +
{`${error.name}: ${error.message}\n\n${error.stack ?? ""}`}
+ ) : null} +
+
+ ); + } +} + +/** Plain passthrough, so a retry can swap the subtree by key without a fragment import dance. */ +function Subtree(props: { children: ReactNode }) { + return <>{props.children}; +} + +const SCREEN: React.CSSProperties = { + position: "fixed", + inset: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "24px", + background: "#f7f5f0", + fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif", + zIndex: 2147483647 +}; + +const PANEL: React.CSSProperties = { + width: "100%", + maxWidth: "520px", + padding: "28px", + borderRadius: "16px", + border: "1px solid rgba(0,0,0,0.08)", + background: "#ffffff", + boxShadow: "0 12px 40px rgba(0,0,0,0.08)" +}; + +const TITLE: React.CSSProperties = { + margin: 0, + fontSize: "18px", + fontWeight: 600, + color: "#1f2933" +}; + +const DESCRIPTION: React.CSSProperties = { + margin: "10px 0 0", + fontSize: "13px", + lineHeight: 1.6, + color: "rgba(31,41,51,0.65)" +}; + +const ACTIONS: React.CSSProperties = { + display: "flex", + flexWrap: "wrap", + gap: "8px", + marginTop: "20px" +}; + +const BUTTON: React.CSSProperties = { + padding: "7px 14px", + fontSize: "13px", + borderRadius: "8px", + border: "1px solid rgba(0,0,0,0.12)", + background: "#ffffff", + color: "#1f2933", + cursor: "pointer" +}; + +const PRIMARY_BUTTON: React.CSSProperties = { + ...BUTTON, + border: "1px solid transparent", + background: "#2f9e8f", + color: "#ffffff" +}; + +const DETAILS_TOGGLE: React.CSSProperties = { + marginTop: "18px", + padding: 0, + fontSize: "12px", + border: "none", + background: "none", + color: "rgba(31,41,51,0.5)", + textDecoration: "underline", + cursor: "pointer" +}; + +const DETAILS: React.CSSProperties = { + marginTop: "10px", + padding: "12px", + maxHeight: "220px", + overflow: "auto", + fontSize: "11px", + lineHeight: 1.5, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + borderRadius: "8px", + background: "rgba(0,0,0,0.04)", + color: "rgba(31,41,51,0.75)" +}; diff --git a/App/frontend/desktop/src/app/legal-question-history.ts b/App/frontend/desktop/src/app/legal-question-history.ts new file mode 100644 index 000000000..0bc4a1ea2 --- /dev/null +++ b/App/frontend/desktop/src/app/legal-question-history.ts @@ -0,0 +1,73 @@ +import type { PluginInteractionRequest } from "@memmy/local-api-contracts"; + +export interface LegalQuestionRecord { + conversationId: string; + callId: string; + agentToolCallId?: string; + turnId?: string; + interactionId: string; + askedAt: number; + title?: string; + questions: Array<{ id: string; label: string }>; + answers: Record | null; + status: "pending" | "answered" | "interrupted"; +} + +const STORAGE_KEY = "memmy.legal-question-history.v1"; +const MAX_RECORDS = 100; + +export function legalQuestionRecordKey(record: Pick): string { + return `${record.conversationId.replace(/^websocket:/u, "")}:${record.callId}:${record.interactionId}`; +} + +export function legalQuestionFields(request: PluginInteractionRequest): LegalQuestionRecord["questions"] { + if (request.type !== "question" || !request.payload || typeof request.payload !== "object") return []; + const questions = (request.payload as { questions?: unknown }).questions; + if (!Array.isArray(questions)) return []; + return questions.flatMap((question) => question && typeof question === "object" + && typeof question.id === "string" && typeof question.label === "string" + && question.id.trim() && question.label.trim() + ? [{ id: question.id, label: question.label }] : []); +} + +export function legalQuestionTitle(request: PluginInteractionRequest): string | undefined { + if (!request.payload || typeof request.payload !== "object") return undefined; + const title = (request.payload as { title?: unknown }).title; + return typeof title === "string" && title.trim() ? title.trim() : undefined; +} + +export function upsertLegalQuestionRecord( + records: readonly LegalQuestionRecord[], next: LegalQuestionRecord, +): LegalQuestionRecord[] { + const key = legalQuestionRecordKey(next); + const index = records.findIndex((record) => legalQuestionRecordKey(record) === key); + if (index < 0) return [...records, next].slice(-MAX_RECORDS); + const updated = [...records]; + updated[index] = { ...updated[index]!, ...next, ...(next.title ? { title: next.title } : { title: updated[index]!.title }), + askedAt: updated[index]!.askedAt }; + return updated; +} + +export function readLegalQuestionHistory(storage?: Pick | null): LegalQuestionRecord[] { + if (!storage) return []; + try { + const parsed: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? "[]"); + if (!Array.isArray(parsed)) return []; + return parsed.filter((value): value is LegalQuestionRecord => Boolean(value) + && typeof value === "object" && typeof value.conversationId === "string" + && typeof value.callId === "string" && typeof value.interactionId === "string" + && Number.isFinite(value.askedAt) && Array.isArray(value.questions) + && (value.title === undefined || typeof value.title === "string") + && value.questions.every((question: unknown) => Boolean(question) && typeof question === "object" + && typeof (question as { id?: unknown }).id === "string" + && typeof (question as { label?: unknown }).label === "string") + && (value.answers === null || (typeof value.answers === "object" && !Array.isArray(value.answers) + && Object.values(value.answers).every((answer) => typeof answer === "string"))) + && ["pending", "answered", "interrupted"].includes(value.status)).slice(-MAX_RECORDS); + } catch { return []; } +} + +export function writeLegalQuestionHistory(records: readonly LegalQuestionRecord[], storage?: Pick | null): void { + try { storage?.setItem(STORAGE_KEY, JSON.stringify(records.slice(-MAX_RECORDS))); } + catch { /* The live process record remains available when browser storage is full. */ } +} diff --git a/App/frontend/desktop/src/app/plugin-interaction-waiting-label.ts b/App/frontend/desktop/src/app/plugin-interaction-waiting-label.ts new file mode 100644 index 000000000..9e2c3940f --- /dev/null +++ b/App/frontend/desktop/src/app/plugin-interaction-waiting-label.ts @@ -0,0 +1,118 @@ +import type { PluginUiCall } from "./plugin-ui-context.js"; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +/** Use the last unanswered interaction, rather than the plugin's running state. */ +export function pendingPluginInteraction( + calls: readonly PluginUiCall[], + answered: ReadonlySet = new Set(), +): { call: PluginUiCall; type: string; cardType: string } | null { + for (const call of [...calls].reverse()) { + if (call.pluginId === "legal-labor" && call.capabilityId === "guide-open") continue; + if (call.events.some((event) => event.type === "result" || event.type === "error")) continue; + for (const event of [...call.events].reverse()) { + if (event.type !== "interaction" + || answered.has(`${call.pluginId}:${call.callId}:${event.request.interactionId}`)) continue; + const payload = record(event.request.payload); + return { call, type: event.request.type, cardType: typeof payload.cardType === "string" ? payload.cardType : "" }; + } + } + return null; +} + +/** A submitted card is still part of the live call until the plugin reports a result. */ +export function hasAnsweredUnresolvedPluginInteraction( + calls: readonly PluginUiCall[], + answered: ReadonlySet = new Set(), +): boolean { + return answeredUnresolvedPluginInteraction(calls, answered) !== null; +} + +export function answeredUnresolvedPluginInteraction( + calls: readonly PluginUiCall[], + answered: ReadonlySet = new Set(), +): { call: PluginUiCall; type: string; cardType: string } | null { + const call = [...calls].reverse().find((candidate) => + !(candidate.pluginId === "legal-labor" && candidate.capabilityId === "guide-open") && + !candidate.events.some((event) => event.type === "result" || event.type === "error")); + if (!call) return null; + const latestInteraction = [...call.events].reverse().find((event) => event.type === "interaction"); + if (latestInteraction?.type !== "interaction" + || !answered.has(`${call.pluginId}:${call.callId}:${latestInteraction.request.interactionId}`)) return null; + const payload = record(latestInteraction.request.payload); + return { call, type: latestInteraction.request.type, cardType: typeof payload.cardType === "string" ? payload.cardType : "" }; +} + +export function questionWaitingLabel(title: string | null | undefined, english = false): string { + const value = title?.trim(); + if (!value) return english ? "Waiting for your answer" : "等待用户回答"; + if (english) return /^Wait(?:ing)?\b/iu.test(value) ? value : `Waiting for ${value}`; + return /^(?:等待|待)/u.test(value) ? value : `等待${value}`; +} + +function pluginInteractionTitle(call: PluginUiCall): string | null { + const event = [...call.events].reverse().find((item) => item.type === "interaction"); + const title = event?.type === "interaction" ? record(event.request.payload).title : null; + return typeof title === "string" && title.trim() ? title.trim() : null; +} + +/** The transcript and sidebar describe the same pending action at different lengths. */ +export function pluginInteractionWaitingLabel( + pending: NonNullable>, + english = false, + compact = false, +): string { + if (pending.call.pluginId === "literature-review") { + const labels: Record = { + "review-spec": ["等待确认研究需求", "待确认需求", "Waiting to confirm research requirements", "Confirm requirements"], + "source-import": ["等待用户补充文献", "待补充文献", "Waiting for local literature", "Add literature"], + keywords: ["等待确认检索关键词", "待确认关键词", "Waiting to confirm search keywords", "Confirm keywords"], + "paper-selection": ["等待筛选参考文献", "待筛选文献", "Waiting to select references", "Select references"], + "fulltext-recovery": ["等待补充文献全文", "待补充全文", "Waiting for full-text documents", "Add full texts"], + outline: ["等待确认综述大纲", "待确认大纲", "Waiting to confirm review outline", "Confirm outline"], + }; + const label = labels[pending.cardType]; + if (label) return label[english ? (compact ? 3 : 2) : (compact ? 1 : 0)]; + } + const title = pluginInteractionTitle(pending.call); + if (title) { + return compact ? english ? `Await ${title}` : `待${title}` + : questionWaitingLabel(title, english); + } + const fallback: Record = { + "file-input": ["等待上传文件", "待上传文件", "Waiting for file upload", "Upload files"], + question: ["等待用户回答", "待用户回答", "Waiting for your answer", "Answer question"], + approval: ["等待确认操作", "待你确认", "Waiting for your approval", "Confirm action"], + "audio-record": ["等待录音", "待录音", "Waiting for recording", "Record audio"], + custom: ["等待完成卡片操作", "待完成操作", "Waiting for card action", "Complete action"], + }; + const label = fallback[pending.type] ?? ["等待你的操作", "待你操作", "Waiting for your action", "Action needed"]; + return label[english ? (compact ? 3 : 2) : (compact ? 1 : 0)]; +} + +/** A submitted card keeps its process action, without the live waiting prefix. */ +export function reviewInteractionCompletedLabel(cardType: string, english = false): string | null { + const review: Record = { + "review-spec": ["确认研究需求", "Confirm research requirements"], + "source-import": ["用户补充文献", "Add local literature"], + keywords: ["确认检索关键词", "Confirm search keywords"], + "paper-selection": ["筛选参考文献", "Select papers"], + "fulltext-recovery": ["补充文献全文", "Add full texts"], + outline: ["确认综述大纲", "Confirm review outline"] + }; + const known = review[cardType]; + return known ? known[english ? 1 : 0] : null; +} + +export function pluginInteractionCompletedLabel( + interaction: { call: PluginUiCall; type: string; cardType: string }, english = false, +): string { + const known = interaction.call.pluginId === "literature-review" + ? reviewInteractionCompletedLabel(interaction.cardType, english) : null; + if (known) return known; + if (interaction.type === "question") return english ? "Asked the user" : "向用户提问"; + if (interaction.type === "file-input") return english ? "Uploaded files" : "用户上传文件"; + return pluginInteractionTitle(interaction.call) ?? (english ? "Completed card action" : "已完成卡片操作"); +} diff --git a/App/frontend/desktop/src/app/plugin-ui-context.tsx b/App/frontend/desktop/src/app/plugin-ui-context.tsx new file mode 100644 index 000000000..b78bf7640 --- /dev/null +++ b/App/frontend/desktop/src/app/plugin-ui-context.tsx @@ -0,0 +1,366 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useEffect, + useRef, + useState, + type ReactNode, + type Dispatch, + type SetStateAction +} from "react"; +import type { UploadedAgentMedia } from "../api/memmy-agent-client.js"; +import type { CapabilityEvent, PluginCapabilityEventPayload, PluginInvocationOrigin } from "@memmy/local-api-contracts"; +import { createTranscriptionJob, type PluginTranscriptionJob, type PluginTranscriptionState, type TranscribeAttempt } from "../lib/plugin-transcription-jobs.js"; +import { pendingPluginInteraction } from "./plugin-interaction-waiting-label.js"; +import { legalQuestionFields, legalQuestionRecordKey, legalQuestionTitle, readLegalQuestionHistory, upsertLegalQuestionRecord, writeLegalQuestionHistory, type LegalQuestionRecord } from "./legal-question-history.js"; + +export interface PluginUiCall { + pluginId: string; + capabilityId: string; + callId: string; + conversationId: string; + /** Who asked for this call; decides occlusion and what closing a card means. */ + origin: PluginInvocationOrigin; + agentToolCallId?: string; + turnId?: string; + events: CapabilityEvent[]; +} + +export interface PluginInvocationContext { + pluginId: string; + capabilityId: string; + conversationId: string; + input: unknown; +} + +export interface PluginChatFeedback { + message: string; + clientRequestId: string; +} + +interface PluginUiContextValue { + registerChatFeedback(conversationId: string, handler: (feedback: PluginChatFeedback) => boolean): () => void; + routeChatFeedback(chatId: string, feedback: PluginChatFeedback): boolean; + fileDrafts: Map; + questionDrafts: Map>; + legalQuestionHistory: LegalQuestionRecord[]; + recordLegalQuestionAnswer(call: Pick, + request: Extract["request"], response: unknown): void; + recordingMaterials: Map; + setRecordingMaterials: Dispatch>>; + addRecordingMaterials(pluginId: string, conversationId: string, files: PluginRecordingMaterial[]): void; + /** Transcription progress of handed-over recordings, by stored audio path. */ + transcriptions: Map; + /** Starts (or returns the running) transcription for a stored recording. */ + startTranscription(audioPath: string, attempt: TranscribeAttempt): PluginTranscriptionJob; + transcriptionJob(audioPath: string): PluginTranscriptionJob | undefined; + /** 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; + closeSurface(): void; + receive(payload: PluginCapabilityEventPayload): void; + reconcilePendingCalls(calls: Array>): void; + interruptPendingInteractions(keys: readonly string[]): void; +} + +/** + * A file staged into the diagnosis upload card from a recording. + * + * `pending` marks the recording itself while it is still being transcribed: + * the row is listed and counts towards submission, and submitting waits for + * its transcript instead of being blocked by it. + */ +export type PluginRecordingMaterial = UploadedAgentMedia & { + /** The file the plugin already holds for this recording, so it is not added twice. */ + collected?: boolean; + /** A recording whose transcript has not arrived yet: listed, and waits on submit. */ + pending?: boolean; + /** + * The recording behind a pending row. + * + * The interview reaches the plugin as text, so a row stands for its + * transcript; if that transcript never arrives the audio is still handed over + * rather than dropped, which is only possible while it is carried here. + */ + pendingAudio?: UploadedAgentMedia; +}; +const EMPTY_RECORDING_MATERIALS: PluginRecordingMaterial[] = []; +function recordingMaterialKey(pluginId: string, conversationId: string) { + return JSON.stringify([pluginId, conversationId.replace(/^websocket:/, "")]); +} + +export { PLUGIN_TRANSCRIPTION_ATTEMPTS, type PluginRecordingTranscript, type PluginTranscriptionJob, type PluginTranscriptionState } from "../lib/plugin-transcription-jobs.js"; + +const MAX_PLUGIN_CALLS = 50; +const PluginUiContext = createContext(null); + +/** + * A websocket chat is addressed by both its public chat id and its internal + * session key. Plugin events can use either spelling, so compare them by a + * stable conversation id before deciding whether they belong together. + */ +export function normalizePluginConversationId(conversationId: string | null | undefined): string | null { + if (typeof conversationId !== "string" || !conversationId) return null; + return conversationId.replace(/^websocket:/, ""); +} + +export function PluginUiProvider(props: { children: ReactNode }) { + const feedbackHandlers = useRef(new Map boolean }>()); + const fileDrafts = useRef(new Map()).current; + const questionDrafts = useRef(new Map>()).current; + const [legalQuestionHistory, setLegalQuestionHistory] = useState(() => + readLegalQuestionHistory(typeof window === "undefined" ? null : window.localStorage)); + useEffect(() => { + writeLegalQuestionHistory(legalQuestionHistory, typeof window === "undefined" ? null : window.localStorage); + }, [legalQuestionHistory]); + const recordLegalQuestionAnswer = useCallback((call: Pick, + request: Extract["request"], response: unknown) => { + if (call.pluginId !== "legal-labor" || call.capabilityId !== "gap-resolve") return; + const questions = legalQuestionFields(request); + if (!questions.length) return; + const raw = response && typeof response === "object" ? response as { answers?: unknown } : {}; + const answerValues = raw.answers && typeof raw.answers === "object" && !Array.isArray(raw.answers) + ? raw.answers as Record : {}; + const answers = Object.fromEntries(questions.flatMap((question) => { + const value = answerValues[question.id]; + return typeof value === "string" && value.trim() ? [[question.id, value.trim()]] : []; + })); + setLegalQuestionHistory((current) => upsertLegalQuestionRecord(current, { + conversationId: call.conversationId, callId: call.callId, interactionId: request.interactionId, + askedAt: Date.now(), title: legalQuestionTitle(request), questions, answers, status: "answered" + })); + }, []); + const [recordingMaterials, setRecordingMaterials] = useState(new Map()); + const addRecordingMaterials = useCallback((pluginId: string, conversationId: string, files: PluginRecordingMaterial[]) => { + if (!files.length) return; + const key = recordingMaterialKey(pluginId, conversationId); + setRecordingMaterials((current) => { + const next = new Map(current); + const merged = new Map((current.get(key) ?? []).map((file) => [file.path, file])); + for (const file of files) merged.set(file.path, file); + next.set(key, [...merged.values()]); + return next; + }); + }, []); + const [hiddenCallIds, setHiddenCallIds] = useState>(() => new Set()); + // Jobs are keyed by the stored audio path: it is the one id the recording + // card, the upload card's file row and the plugin's material all share. + const transcriptionJobs = useRef(new Map()).current; + const [transcriptions, setTranscriptions] = useState(new Map()); + const startTranscription = useCallback((audioPath: string, attempt: TranscribeAttempt) => { + const existing = transcriptionJobs.get(audioPath); + if (existing) return existing; + const job = createTranscriptionJob(attempt, (state) => setTranscriptions((current) => new Map(current).set(audioPath, state))); + transcriptionJobs.set(audioPath, job); + setTranscriptions((current) => new Map(current).set(audioPath, job.state)); + return job; + }, [transcriptionJobs]); + const transcriptionJob = useCallback((audioPath: string) => transcriptionJobs.get(audioPath), [transcriptionJobs]); + 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 }); + return () => { feedbackHandlers.current.delete(id); }; + }, []); + const routeChatFeedback = useCallback((chatId: string, feedback: PluginChatFeedback) => { + const sessionKey = chatId.startsWith("websocket:") ? chatId : `websocket:${chatId}`; + for (const entry of [...feedbackHandlers.current.values()].reverse()) { + if (entry.conversationId === chatId || entry.conversationId === sessionKey) { + if (entry.handler(feedback)) return true; + } + } + return false; + }, []); + const [calls, setCalls] = useState([]); + const [activeSurface, setActiveSurface] = useState(null); + const receive = useCallback((payload: PluginCapabilityEventPayload) => { + setCalls((current) => reducePluginUiCalls(current, payload)); + if (payload.pluginId !== "legal-labor" || payload.capabilityId !== "gap-resolve") return; + const event = payload.event; + if (event.type === "interaction") { + const questions = legalQuestionFields(event.request); + if (!questions.length) return; + setLegalQuestionHistory((current) => { + const key = legalQuestionRecordKey({ conversationId: payload.conversationId, callId: payload.callId, + interactionId: event.request.interactionId }); + if (current.some((record) => legalQuestionRecordKey(record) === key)) return current; + return upsertLegalQuestionRecord(current, { conversationId: payload.conversationId, callId: payload.callId, + agentToolCallId: payload.agentToolCallId, turnId: payload.turnId, + interactionId: event.request.interactionId, askedAt: Date.now(), title: legalQuestionTitle(event.request), + questions, answers: null, status: "pending" }); + }); + } else if (event.type === "result" || event.type === "error") { + setLegalQuestionHistory((current) => current.map((record) => record.callId === payload.callId && record.status === "pending" + ? { ...record, status: "interrupted" } : record)); + } + }, []); + const reconcilePendingCalls = useCallback((activeCalls: Array>) => { + const key = (call: Pick) => + JSON.stringify([call.pluginId, call.capabilityId, call.callId, normalizePluginConversationId(call.conversationId)]); + const active = new Set(activeCalls.map(key)); + setCalls((current) => current.filter((call) => call.events.some((event) => event.type === "result" || event.type === "error") + || active.has(key(call)))); + setLegalQuestionHistory((current) => current.map((record) => record.status === "pending" + && !activeCalls.some((call) => call.pluginId === "legal-labor" && call.capabilityId === "gap-resolve" + && call.callId === record.callId && normalizePluginConversationId(call.conversationId) === normalizePluginConversationId(record.conversationId)) + ? { ...record, status: "interrupted" } : record)); + }, []); + const interruptPendingInteractions = useCallback((keys: readonly string[]) => { + if (!keys.length) return; + const interrupted = new Set(keys); + setAnsweredInteractions((current) => new Set([...current, ...interrupted])); + setLegalQuestionHistory((current) => current.map((record) => record.status === "pending" + && interrupted.has(`legal-labor:${record.callId}:${record.interactionId}`) + ? { ...record, status: "interrupted" } : record)); + }, []); + const openSurface = useCallback((context: PluginInvocationContext) => setActiveSurface(context), []); + const closeSurface = useCallback(() => setActiveSurface(null), []); + const value = useMemo(() => ({ calls, activeSurface, openSurface, closeSurface, receive, reconcilePendingCalls, interruptPendingInteractions, registerChatFeedback, routeChatFeedback, fileDrafts, questionDrafts, + legalQuestionHistory, recordLegalQuestionAnswer, + hiddenCallIds, setHiddenCallIds, hiddenInteractionSteps, dismissedCallIds, setDismissedCallIds, + answeredInteractions, setAnsweredInteractions, openingCommands, recordingMaterials, setRecordingMaterials, addRecordingMaterials, + transcriptions, startTranscription, transcriptionJob + }), [activeSurface, calls, closeSurface, openSurface, receive, reconcilePendingCalls, interruptPendingInteractions, registerChatFeedback, routeChatFeedback, fileDrafts, questionDrafts, + legalQuestionHistory, recordLegalQuestionAnswer, + hiddenCallIds, hiddenInteractionSteps, dismissedCallIds, answeredInteractions, openingCommands, recordingMaterials, addRecordingMaterials, + transcriptions, startTranscription, transcriptionJob]); + return {props.children}; +} + +export function useOptionalPluginUi(): PluginUiContextValue | null { return useContext(PluginUiContext); } + +export function usePluginUi(): PluginUiContextValue { + const value = useContext(PluginUiContext); + if (!value) throw new Error("usePluginUi must be used within PluginUiProvider"); + return value; +} + +/** Presentation-only waiting state; does not end or interrupt the owning Agent run. */ +export function usePendingPluginInteraction(conversationId: string | null | undefined): ReturnType { + const value = useContext(PluginUiContext); + const normalized = normalizePluginConversationId(conversationId); + if (!normalized || !value) return null; + return pendingPluginInteraction(value.calls.filter((call) => normalizePluginConversationId(call.conversationId) === normalized + && !value.hiddenCallIds.has(call.callId) && !value.dismissedCallIds.has(call.callId)), + value.answeredInteractions); +} + +export function usePluginInteractionWaiting(conversationId: string | null | undefined): boolean { + return usePendingPluginInteraction(conversationId) !== null; +} + +export function reducePluginUiCalls( + calls: PluginUiCall[], + payload: PluginCapabilityEventPayload +): PluginUiCall[] { + const payloadConversationId = normalizePluginConversationId(payload.conversationId); + const index = calls.findIndex((call) => call.pluginId === payload.pluginId + && call.capabilityId === payload.capabilityId + && call.callId === payload.callId + && normalizePluginConversationId(call.conversationId) === payloadConversationId); + if (index < 0) { + return [...calls, { + pluginId: payload.pluginId, + capabilityId: payload.capabilityId, + callId: payload.callId, + conversationId: payload.conversationId, + origin: payload.origin, + agentToolCallId: payload.agentToolCallId, turnId: payload.turnId, + events: [payload.event] + }].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]; + next[index] = { ...current, events: mergeCapabilityEvent(current.events, payload.event) }; + return next; +} + +function mergeCapabilityEvent(events: CapabilityEvent[], event: CapabilityEvent): CapabilityEvent[] { + const replaces = (candidate: CapabilityEvent): boolean => { + if (event.type === "progress" || event.type === "task-list") return candidate.type === event.type; + if (event.type === "result" || event.type === "error") return candidate.type === "result" || candidate.type === "error"; + if (event.type === "interaction") { + return candidate.type === "interaction" && candidate.request.interactionId === event.request.interactionId; + } + return event.type === "artifact" && candidate.type === "artifact" && candidate.artifact.id === event.artifact.id; + }; + const index = events.findIndex(replaces); + if (index < 0) return [...events, event]; + const next = [...events]; + next[index] = event; + return next; +} + +/** Optional for standalone renderer previews; routes only messages from the same conversation. */ +export function usePluginChatFeedback(conversationId: string | undefined, handler: (feedback: PluginChatFeedback) => boolean) { + const context = useContext(PluginUiContext); + const latest = useRef(handler); + latest.current = handler; + const register = context?.registerChatFeedback; + useEffect(() => { + if (register && conversationId) return register(conversationId, (feedback) => latest.current(feedback)); + }, [register, conversationId]); + return context?.fileDrafts; +} + +/** Uploaded recordings can arrive before or after the material card mounts. */ +export function usePluginRecordingMaterials(pluginId?: string, conversationId?: string) { + const context = useContext(PluginUiContext); + const key = pluginId && conversationId ? recordingMaterialKey(pluginId, conversationId) : null; + const files = key ? context?.recordingMaterials.get(key) ?? EMPTY_RECORDING_MATERIALS : EMPTY_RECORDING_MATERIALS; + const setter = context?.setRecordingMaterials; + const update = useCallback((nextFiles: PluginRecordingMaterial[]) => { + if (!key || !setter) return; + setter((current) => { const next = new Map(current); next.set(key, nextFiles); return next; }); + }, [key, setter]); + return { files, update }; +} + +/** + * The page-level transcription jobs, for cards that hand a recording over + * before it is transcribed. Null outside a {@link PluginUiProvider}, where a + * card falls back to transcribing in place. + */ +export function usePluginTranscriptions() { + const context = useContext(PluginUiContext); + return context ? { + transcriptions: context.transcriptions, + startTranscription: context.startTranscription, + transcriptionJob: context.transcriptionJob, + addRecordingMaterials: context.addRecordingMaterials + } : null; +} + +/** Keep typed legal answers when a settings route temporarily unmounts the chat. */ +export function usePluginQuestionDrafts() { return useContext(PluginUiContext)?.questionDrafts; } + +export function useLegalQuestionHistory(): LegalQuestionRecord[] { + return useContext(PluginUiContext)?.legalQuestionHistory ?? []; +} + +export function useRecordLegalQuestionAnswer() { + return useContext(PluginUiContext)?.recordLegalQuestionAnswer; +} + +/** Optional for native question rendering outside the desktop plugin provider. */ +export function useOptionalPluginUiCalls(): PluginUiCall[] { + return useContext(PluginUiContext)?.calls ?? []; +} diff --git a/App/frontend/desktop/src/app/providers.tsx b/App/frontend/desktop/src/app/providers.tsx index e7ae54907..6508be463 100644 --- a/App/frontend/desktop/src/app/providers.tsx +++ b/App/frontend/desktop/src/app/providers.tsx @@ -7,6 +7,7 @@ import { AppStateProvider, useAppState } from "../state/app-state.js"; import { ThemeProvider } from "../theme/theme-provider.js"; import { useWindowFullScreenSync } from "../utils/window-fullscreen.js"; import { resolveDesktopDisplayLanguage } from "./account-channel.js"; +import { PluginUiProvider } from "./plugin-ui-context.js"; /** Contract for api clients context value. */ export interface ApiClientsContextValue { @@ -21,9 +22,11 @@ export function AppProviders(props: { children: ReactNode }) { return ( - - {props.children} - + + + {props.children} + + ); diff --git a/App/frontend/desktop/src/app/router.tsx b/App/frontend/desktop/src/app/router.tsx index 38c0627d3..5d07ff446 100644 --- a/App/frontend/desktop/src/app/router.tsx +++ b/App/frontend/desktop/src/app/router.tsx @@ -64,6 +64,7 @@ import { LoginPage } from "../pages/login-page.js"; import { writeMemorySubPage } from "../pages/memory-sub-page-storage.js"; import { OnboardingPage } from "../pages/onboarding-page.js"; import { PetPage } from "../pages/pet-page.js"; +import { PluginSurfacePage } from "../pages/plugin-surface-page.js"; import { PetStartupScreen } from "../pages/pet-startup-screen.js"; import { StartupScreen } from "../pages/startup-screen.js"; import { TokenDetailPage } from "../pages/token-detail-page.js"; @@ -282,7 +283,8 @@ export function AppRouter(props: { onRetry: () => void }) { return ( <> - {(currentPath === "/main" || (preserveHomeBrowser + {(currentPath === "/main" || ((preserveHomeBrowser || state.navigation.recordingRequestId > 0 + || state.navigation.recordingStatus !== "idle") && resolveMainWindowActionRoute(currentPath) === "workspace")) && (
; case "/memory-sources": return ; + case "/plugin": + return ; case "/settings": return ; case "/pet": diff --git a/App/frontend/desktop/src/app/routes.ts b/App/frontend/desktop/src/app/routes.ts index df4263bb4..82c2790d3 100644 --- a/App/frontend/desktop/src/app/routes.ts +++ b/App/frontend/desktop/src/app/routes.ts @@ -19,6 +19,7 @@ export type AppRoutePath = | "/memory" | "/knowledge" | "/memory-sources" + | "/plugin" | "/settings"; const CURRENT_ROUTE_STORAGE_KEY = "memmy.currentRoute"; @@ -56,6 +57,7 @@ export const routeTable: Record = { "/memory": { path: "/memory", navKey: "nav.memory", requiresBootstrap: true }, "/knowledge": { path: "/knowledge", navKey: "nav.knowledge", requiresBootstrap: true }, "/memory-sources": { path: "/memory-sources", navKey: "nav.memory", requiresBootstrap: true }, + "/plugin": { path: "/plugin", navKey: "plugin.surface.title", requiresBootstrap: true }, "/settings": { path: "/settings", navKey: "nav.settings", requiresBootstrap: true } }; diff --git a/App/frontend/desktop/src/app/tests/agent-runtime-bridge.test.tsx b/App/frontend/desktop/src/app/tests/agent-runtime-bridge.test.tsx index 0704521c5..e138e5bf0 100644 --- a/App/frontend/desktop/src/app/tests/agent-runtime-bridge.test.tsx +++ b/App/frontend/desktop/src/app/tests/agent-runtime-bridge.test.tsx @@ -99,6 +99,7 @@ describe("AgentRuntimeBridge", () => { expect(isAgentRuntimeBridgeRoute("/settings")).toBe(true); expect(isAgentRuntimeBridgeRoute("/memory")).toBe(true); expect(isAgentRuntimeBridgeRoute("/memory-sources")).toBe(true); + expect(isAgentRuntimeBridgeRoute("/plugin")).toBe(true); expect(isAgentRuntimeBridgeRoute("/pet")).toBe(true); expect(isAgentRuntimeBridgeRoute("/welcome")).toBe(false); diff --git a/App/frontend/desktop/src/app/tests/error-boundary.test.tsx b/App/frontend/desktop/src/app/tests/error-boundary.test.tsx new file mode 100644 index 000000000..90e52769a --- /dev/null +++ b/App/frontend/desktop/src/app/tests/error-boundary.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorBoundary } from "../error-boundary.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; +let errorSpy: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + // React logs the caught error itself; keeping that out of the test output + // is not the same as ignoring it, which the boundary test below checks. + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + errorSpy.mockRestore(); +}); + +/** + * Throws for as long as `fuse.lit` is true, renders a marker otherwise. + * + * The switch is external rather than a render counter on purpose: React 19 + * retries a throwing render once before it commits the error, so a component + * that only blows up on its first render is quietly rescued by that retry and + * the boundary is never exercised. A fuse that stays lit is what a real crash + * looks like — the same bad state on every attempt. + */ +function Bomb(props: { fuse: { lit: boolean } }): ReactNode { + if (props.fuse.lit) throw new Error("kaboom"); + return

rendered

; +} + +describe("ErrorBoundary", () => { + it("renders children untouched when nothing throws", () => { + act(() => { + root.render( + +

hello

+
+ ); + }); + expect(container.querySelector("[data-testid=content]")?.textContent).toBe("hello"); + expect(container.querySelector("[role=alert]")).toBeNull(); + }); + + it("replaces a blank window with a crash screen that names the error", async () => { + await act(async () => { + root.render( + + + + ); + }); + + // This is the whole point: before the boundary, a throw here unmounted the + // tree and left #root empty — a white page with nothing to read. + expect(container.querySelector("[role=alert]")).not.toBeNull(); + expect(container.querySelector("[data-testid=content]")).toBeNull(); + + const detailsToggle = [...container.querySelectorAll("button")].find((button) => /详情|details/i.test(button.textContent ?? "")); + act(() => detailsToggle?.click()); + expect(container.querySelector("pre")?.textContent).toContain("kaboom"); + }); + + it("logs the error and its component stack so the log file can explain a report", async () => { + await act(async () => { + root.render( + + + + ); + }); + const boundaryLog = errorSpy.mock.calls.find((call) => String(call[0]).includes("Renderer error boundary caught")); + expect(boundaryLog).toBeDefined(); + expect(boundaryLog?.[1]).toBeInstanceOf(Error); + expect(String(boundaryLog?.[2])).toContain("Bomb"); + }); + + it("retry remounts the subtree fresh instead of resuming the broken one", async () => { + const fuse = { lit: true }; + await act(async () => { + root.render( + + + + ); + }); + expect(container.querySelector("[role=alert]")).not.toBeNull(); + + // Whatever was wrong has been fixed by the time the user presses retry. + fuse.lit = false; + const retry = [...container.querySelectorAll("button")].find((button) => /重试|try again/i.test(button.textContent ?? "")); + expect(retry).toBeDefined(); + await act(async () => retry?.click()); + + expect(container.querySelector("[role=alert]")).toBeNull(); + expect(container.querySelector("[data-testid=content]")?.textContent).toBe("rendered"); + }); + + it("does not depend on the app's providers, so a provider that throws is still caught", async () => { + function BrokenProvider(): ReactNode { + throw new Error("provider exploded"); + } + await act(async () => { + root.render( + + + + ); + }); + expect(container.querySelector("[role=alert]")).not.toBeNull(); + }); +}); 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 new file mode 100644 index 000000000..4f9107afe --- /dev/null +++ b/App/frontend/desktop/src/app/tests/plugin-ui-context.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { PluginUiProvider, usePluginUi, usePluginInteractionWaiting, 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"); + }); + + it("keeps same call ids isolated when two conversations invoke a plugin", () => { + const base = { + pluginId: "legal-labor", + capabilityId: "session-start", + callId: "reused-call-id", + origin: "agent" as const + }; + let calls: PluginUiCall[] = []; + calls = reducePluginUiCalls(calls, { + ...base, + conversationId: "websocket:chat-1", + event: { type: "interaction", request: { interactionId: "guide-1", type: "question", payload: {} } } + }); + calls = reducePluginUiCalls(calls, { + ...base, + conversationId: "websocket:chat-2", + event: { type: "interaction", request: { interactionId: "guide-2", type: "question", payload: {} } } + }); + + expect(calls).toHaveLength(2); + expect(calls.map((call) => call.conversationId)).toEqual(["websocket:chat-1", "websocket:chat-2"]); + expect(calls[0]?.events[0]).toMatchObject({ type: "interaction", request: { interactionId: "guide-1" } }); + expect(calls[1]?.events[0]).toMatchObject({ type: "interaction", request: { interactionId: "guide-2" } }); + }); + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + }); + + it("keeps invocation context in memory and clears it explicitly", async () => { + function Harness() { + const ui = usePluginUi(); + return <> + {ui.activeSurface?.pluginId ?? "closed"} + + + ; + } + await act(async () => root.render()); + await act(async () => container.querySelectorAll("button")[0]?.click()); + expect(container.textContent).toContain("com.example.review"); + await act(async () => container.querySelectorAll("button")[1]?.click()); + expect(container.textContent).toContain("closed"); + }); + + it("reports a pending card only for its owning chat and clears the indicator on answer", async () => { + let ui!: ReturnType; + function Harness() { + ui = usePluginUi(); + const waiting = usePluginInteractionWaiting("chat-1"); + const unrelated = usePluginInteractionWaiting("chat-2"); + return {String(waiting)}:{String(unrelated)}; + } + await act(async () => root.render()); + await act(async () => ui.receive({ pluginId: "literature-review", capabilityId: "review_request_interaction", callId: "call", conversationId: "websocket:chat-1", origin: "agent", + event: { type: "interaction", request: { interactionId: "card", type: "custom", payload: {} } } })); + expect(container.textContent).toBe("true:false"); + await act(async () => ui.setAnsweredInteractions(new Set(["literature-review:call:card"]))); + expect(container.textContent).toBe("false:false"); + }); + + it("removes a stale legal question after reconnect while keeping the live call in another chat", async () => { + let ui!: ReturnType; + function Harness() { + ui = usePluginUi(); + return {String(usePluginInteractionWaiting("chat-1"))}:{String(usePluginInteractionWaiting("chat-2"))}; + } + await act(async () => root.render()); + const call = { pluginId: "legal-labor", capabilityId: "gap-resolve", callId: "reused-id", origin: "agent" as const, + event: { type: "interaction" as const, request: { interactionId: "question", type: "question" as const, + payload: { title: "补充企业情况", questions: [{ id: "location", label: "参保地" }] } } } }; + await act(async () => { + ui.receive({ ...call, conversationId: "websocket:chat-1" }); + ui.receive({ ...call, conversationId: "websocket:chat-2" }); + }); + expect(container.textContent).toBe("true:true"); + await act(async () => ui.reconcilePendingCalls([{ pluginId: "legal-labor", capabilityId: "gap-resolve", + callId: "reused-id", conversationId: "websocket:chat-2" }])); + expect(container.textContent).toBe("false:true"); + expect(ui.calls.map((entry) => entry.conversationId)).toEqual(["websocket:chat-2"]); + expect(ui.legalQuestionHistory.find((entry) => entry.conversationId === "websocket:chat-1")?.status).toBe("interrupted"); + expect(ui.legalQuestionHistory.find((entry) => entry.conversationId === "websocket:chat-2")?.status).toBe("pending"); + }); + + it("closes a waiting legal card when a new message interrupts its interaction", async () => { + let ui!: ReturnType; + function Harness() { + ui = usePluginUi(); + return {String(usePluginInteractionWaiting("chat-1"))}; + } + await act(async () => root.render()); + await act(async () => ui.receive({ pluginId: "legal-labor", capabilityId: "gap-resolve", callId: "old-call", + conversationId: "websocket:chat-1", origin: "agent", event: { type: "interaction", request: { + interactionId: "question", type: "question", payload: { title: "补充企业情况", + questions: [{ id: "location", label: "实际参保地区" }] } + } } })); + expect(container.textContent).toBe("true"); + await act(async () => ui.interruptPendingInteractions(["legal-labor:old-call:question"])); + expect(container.textContent).toBe("false"); + expect(ui.legalQuestionHistory.find((entry) => entry.callId === "old-call")?.status).toBe("interrupted"); + expect(ui.legalQuestionHistory.find((entry) => entry.callId === "old-call")?.answers).toBeNull(); + }); +}); diff --git a/App/frontend/desktop/src/app/tests/routes.test.ts b/App/frontend/desktop/src/app/tests/routes.test.ts index b3532f6ef..cc7d1fd44 100644 --- a/App/frontend/desktop/src/app/tests/routes.test.ts +++ b/App/frontend/desktop/src/app/tests/routes.test.ts @@ -100,6 +100,7 @@ describe("desktop route table", () => { "/memory", "/knowledge", "/memory-sources", + "/plugin", "/settings" ]); }); @@ -668,6 +669,7 @@ describe("desktop route table", () => { it("restores the current window route across renderer reloads after onboarding", () => { expect(resolveReloadedInitialView("/main", "/memory-sources")).toBe("/memory-sources"); + expect(resolveReloadedInitialView("/main", "/plugin")).toBe("/main"); expect(resolveReloadedInitialView("/pet", "/settings")).toBe("/settings"); expect(resolveReloadedInitialView("/main", "/pet")).toBe("/main"); expect(resolveReloadedInitialView("/pet", "/pet")).toBe("/pet"); diff --git a/App/frontend/desktop/src/app/tests/runtime-app-source.test.ts b/App/frontend/desktop/src/app/tests/runtime-app-source.test.ts index dbd33bf02..c5b2ee1df 100644 --- a/App/frontend/desktop/src/app/tests/runtime-app-source.test.ts +++ b/App/frontend/desktop/src/app/tests/runtime-app-source.test.ts @@ -43,6 +43,14 @@ describe("RuntimeApp bootstrap loading", () => { expect(source).toContain("formatAgentSourceScanRequestError(error, undefined, translationRef.current)"); }); + it("routes validated plugin capability events into the plugin UI host", () => { + const source = readFileSync(resolve(__dirname, "../..", "app.tsx"), "utf8"); + + expect(source).toContain('events.addEventListener("plugin.capability_event"'); + expect(source).toContain('parsed?.type === "plugin.capability_event"'); + expect(source).toContain("receivePluginEvent(parsed.payload)"); + }); + it("reconciles scan status on heartbeat only while a scan is showing as active", () => { const source = readFileSync(resolve(__dirname, "../..", "app.tsx"), "utf8"); diff --git a/App/frontend/desktop/src/app/tests/window-drag-region.test.tsx b/App/frontend/desktop/src/app/tests/window-drag-region.test.tsx index c3e177d84..f9c20a2f6 100644 --- a/App/frontend/desktop/src/app/tests/window-drag-region.test.tsx +++ b/App/frontend/desktop/src/app/tests/window-drag-region.test.tsx @@ -7,6 +7,7 @@ import { resolveObservedWindowDragExclusionSelector, resolveWindowDragExclusionSelector, THREAD_DRAG_EXCLUSION_SELECTOR, + TOOLS_DRAG_EXCLUSION_SELECTOR, WINDOWS_TITLEBAR_DRAG_EXCLUSION_SELECTOR, WindowDragRegion } from "../window-drag-region.js"; @@ -21,7 +22,7 @@ afterEach(async () => { }); describe("WindowDragRegion", () => { - it("enables the dynamic exclusion only on the knowledge route", () => { + it("selects the interactive control exclusions for each route", () => { expect(resolveWindowDragExclusionSelector("/knowledge")).toBe( KNOWLEDGE_DRAG_EXCLUSION_SELECTOR ); @@ -29,6 +30,7 @@ describe("WindowDragRegion", () => { THREAD_DRAG_EXCLUSION_SELECTOR ); expect(resolveWindowDragExclusionSelector("/settings")).toBeUndefined(); + expect(resolveWindowDragExclusionSelector("/tools")).toBe(TOOLS_DRAG_EXCLUSION_SELECTOR); expect(resolveObservedWindowDragExclusionSelector(undefined)).toBe( WINDOWS_TITLEBAR_DRAG_EXCLUSION_SELECTOR ); @@ -37,6 +39,34 @@ describe("WindowDragRegion", () => { ); }); + it("keeps the entire tools tab strip clickable across window resizing", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const tabs = document.createElement("div"); + tabs.dataset.windowDragExclusion = "tools-tabs"; + let width = 330; + tabs.getBoundingClientRect = () => ({ + x: 288, y: 24, left: 288, top: 24, right: 288 + width, + bottom: 60, width, height: 36, toJSON: () => undefined + }); + document.body.append(tabs); + const container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root!.render(); + }); + const exclusion = () => container.querySelector(".window-drag-exclusion--dynamic"); + expect(exclusion()?.style.top).toBe("24px"); + expect(exclusion()?.style.height).toBe("36px"); + expect(exclusion()?.style.width).toBe("330px"); + width = 480; + await act(async () => { + window.dispatchEvent(new Event("resize")); + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + }); + expect(exclusion()?.style.width).toBe("480px"); + }); + it("mirrors every knowledge control group as a root-level no-drag exclusion", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const trigger = document.createElement("button"); diff --git a/App/frontend/desktop/src/app/window-drag-region.tsx b/App/frontend/desktop/src/app/window-drag-region.tsx index ba555a61f..3bd0eae88 100644 --- a/App/frontend/desktop/src/app/window-drag-region.tsx +++ b/App/frontend/desktop/src/app/window-drag-region.tsx @@ -6,6 +6,8 @@ export const KNOWLEDGE_DRAG_EXCLUSION_SELECTOR = '[data-window-drag-exclusion^="knowledge-"]'; export const THREAD_DRAG_EXCLUSION_SELECTOR = '[data-window-drag-exclusion^="thread-"]'; +export const TOOLS_DRAG_EXCLUSION_SELECTOR = + '[data-window-drag-exclusion="tools-tabs"]'; export const WINDOWS_TITLEBAR_DRAG_EXCLUSION_SELECTOR = '[data-window-drag-exclusion="windows-titlebar"]'; @@ -47,6 +49,7 @@ function sameRects( export function resolveWindowDragExclusionSelector(path: string): string | undefined { if (path === "/knowledge") return KNOWLEDGE_DRAG_EXCLUSION_SELECTOR; if (path === "/main") return THREAD_DRAG_EXCLUSION_SELECTOR; + if (path === "/tools") return TOOLS_DRAG_EXCLUSION_SELECTOR; return undefined; } diff --git a/App/frontend/desktop/src/components/Select.tsx b/App/frontend/desktop/src/components/Select.tsx index 92995123d..af4e34312 100644 --- a/App/frontend/desktop/src/components/Select.tsx +++ b/App/frontend/desktop/src/components/Select.tsx @@ -29,6 +29,8 @@ export interface SelectProps { menuClassName?: string; labelClassName?: string; placement?: "top" | "bottom"; + /** Optional title shown on the trigger; defaults to the selected option label. */ + title?: string; /** Optional footer rendered below options; receives a close helper. */ menuFooter?: (api: { close: () => void }) => ReactNode; /** Optional panel shown beside the menu while an option is hovered or focused. */ @@ -158,7 +160,7 @@ export function Select(props: SelectProps) { aria-haspopup="listbox" aria-label={props.ariaLabel} aria-labelledby={props.label ? `${labelId} ${controlId}` : undefined} - title={selectedOption?.label} + title={props.title ?? selectedOption?.label} disabled={props.disabled} onClick={() => setOpen((value) => !value)} onKeyDown={handleKeyDown} diff --git a/App/frontend/desktop/src/components/agent-model-selector.tsx b/App/frontend/desktop/src/components/agent-model-selector.tsx index 7158bcbb1..ac8c87c8d 100644 --- a/App/frontend/desktop/src/components/agent-model-selector.tsx +++ b/App/frontend/desktop/src/components/agent-model-selector.tsx @@ -28,6 +28,10 @@ export interface AgentModelSelectorProps { * still tracked separately in Agent state. */ onDefaultModelSelected?: (candidateId: string) => void; + /** When set, the selector stays on the official model and cannot be opened. */ + locked?: boolean; + lockReason?: string; + forcedPresetId?: string | null; } /** Per-chat catalog preset picker. Selection lives in Agent state, never browser storage. */ @@ -38,13 +42,16 @@ export function AgentModelSelector(props: AgentModelSelectorProps) { const candidates = getTaskModelCandidates(workspace, props.mode); const committedSelection = state.agent.committedModelSelectionByScope[props.scopeKey]; const pendingPreset = state.agent.pendingPresetByScope[props.scopeKey]; - const selectedPreset = pendingPreset + const selectedPreset = props.forcedPresetId + ?? pendingPreset ?? committedSelection?.presetId ?? null; const resolved = resolveModelSelection(workspace, props.mode, selectedPreset, { - allowUnassignedSelected: pendingPreset == null && Boolean(committedSelection) + allowUnassignedSelected: pendingPreset == null && !props.forcedPresetId && Boolean(committedSelection) }); const hasNoModels = candidates.length === 0 && !resolved.candidate; + const locked = Boolean(props.locked); + const lockReason = props.lockReason ?? t("home.modelSelector.pluginLocked"); const optionForCandidate = (candidate: (typeof candidates)[number]): SelectOption => ({ value: candidate.id, @@ -102,7 +109,11 @@ export function AgentModelSelector(props: AgentModelSelectorProps) { const thinkingConfig = resolveThinkingConfigForModel(workspace, resolved.candidate); return ( -
+
activateOther(activeQuestion)} + onChange={(event) => setDraft((current) => ({ + ...current, + [activeQuestion.id]: { + ...current[activeQuestion.id]!, + otherText: event.target.value, + otherSelected: true, + skipped: false, + }, + }))} + placeholder={t("home.question.other")} + aria-label={t("home.question.otherPlaceholder")} + /> +
+ ) : null} +
+
+
+ {activeQuestion.allowMultiple && selectedCount > 0 ? ( + {t("home.question.selectedCount", { count: selectedCount })} + ) : } + {submitFailed ? {t("home.question.submitFailed")} : null} + + {currentAnswered && (activeQuestion.allowMultiple || activeDraft.otherSelected) ? ( + + ) : null} +
+ + ); + return dock === undefined ? card : dock ? createPortal(card, dock, props.card.requestId) : null; +} diff --git a/App/frontend/desktop/src/pages/agent-queued-message-list.css b/App/frontend/desktop/src/pages/agent-queued-message-list.css new file mode 100644 index 000000000..2262fa428 --- /dev/null +++ b/App/frontend/desktop/src/pages/agent-queued-message-list.css @@ -0,0 +1,176 @@ +.agent-queue-panel { + max-height: min(240px, 36dvh); + overflow: hidden; + padding: 4px 10px; + border-radius: 16px; + background: color-mix(in srgb, var(--color-border-stone) 16%, var(--color-background-paper)); +} + +.agent-composer-flow > .agent-queue-panel { margin-bottom: 8px; } + +.agent-queue-list { + display: flex; + flex-direction: column; + gap: 0; + max-height: min(240px, 36dvh); + overflow-y: auto; + overscroll-behavior: contain; +} + +.agent-queue-item { + position: relative; + display: flex; + min-height: 48px; + align-items: center; + gap: 9px; + padding: 0 2px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 35%, transparent); + background: transparent; +} + +.agent-queue-item:last-child { border-bottom: 0; } +.agent-queue-item[draggable="true"] { cursor: grab; } +.agent-queue-item--dragging { opacity: 0.45; } +.agent-queue-item--drop-before::before, +.agent-queue-item--drop-after::after { + content: ""; + position: absolute; + right: 4px; + left: 4px; + height: 3px; + border-radius: 2px; + background: var(--color-action-sky); +} +.agent-queue-item--drop-before::before { top: -4px; } +.agent-queue-item--drop-after::after { bottom: -4px; } +.agent-queue-item__handle { + display: inline-flex; + width: 20px; + height: 28px; + flex: 0 0 20px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 68%, transparent); + cursor: grab; +} +.agent-queue-item__handle--static { cursor: default; } +.agent-queue-item__handle:focus-visible { outline: 2px solid var(--color-action-sky); } +.agent-queue-item__media { + position: relative; + display: inline-flex; + width: 26px; + height: 26px; + flex: 0 0 26px; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + border-radius: 6px; + background: var(--color-background-paper); + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); +} +.agent-queue-item__media img { width: 100%; height: 100%; object-fit: cover; } +.agent-queue-item__media .file-type-icon.file-type-icon--card { --file-icon-card-size: 22px; } +.agent-queue-item__media-count { + position: absolute; + right: 1px; + bottom: 1px; + padding: 1px 3px; + border-radius: 4px; + background: rgb(26 39 31 / 0.83); + color: white; + font-size: 8px; + line-height: 10px; +} + +.agent-queue-item__source { + display: inline-flex; + width: 20px; + height: 20px; + flex: 0 0 20px; + align-items: center; + justify-content: center; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); +} + +.agent-queue-item__source-mark { + display: block; + width: 18px; + height: 18px; + max-width: 18px; + max-height: 18px; + flex: 0 0 18px; + object-fit: contain; +} + +.agent-queue-item__source-image { + border-radius: 3px; +} + +.agent-queue-item__text { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + font-size: 14px; + line-height: 22px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-queue-item__actions { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: flex-end; + gap: 4px; + margin-left: auto; +} + +.agent-queue-item__remove, +.agent-queue-item__steer, +.agent-queue-item__edit { + display: inline-flex; + width: 26px; + height: 26px; + min-width: 26px; + flex: 0 0 26px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + cursor: pointer; + transition: background 140ms ease, color 140ms ease, opacity 140ms ease; +} + +.agent-queue-item__steer:hover:not(:disabled), +.agent-queue-item__edit:hover:not(:disabled), +.agent-queue-item__remove:hover:not(:disabled) { + background: var(--color-canvas-oat); +} + +.agent-queue-item__remove:hover:not(:disabled) { + color: var(--color-status-error); +} + +.agent-queue-item__remove:focus-visible, +.agent-queue-item__steer:focus-visible, +.agent-queue-item__edit:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 32%, transparent); +} + +.agent-queue-item__remove:disabled, +.agent-queue-item__steer:disabled, +.agent-queue-item__edit:disabled { + cursor: not-allowed; + opacity: 0.38; +} diff --git a/App/frontend/desktop/src/pages/agent-queued-message-list.tsx b/App/frontend/desktop/src/pages/agent-queued-message-list.tsx index edbb60c08..d58013fe2 100644 --- a/App/frontend/desktop/src/pages/agent-queued-message-list.tsx +++ b/App/frontend/desktop/src/pages/agent-queued-message-list.tsx @@ -1,8 +1,11 @@ /** Queued WebUI message list displayed above the chat composer. */ -import { useEffect, useRef, type ReactNode } from "react"; -import { Monitor, SquareTerminal } from "lucide-react"; +import { useEffect, useRef, useState, type DragEvent } from "react"; +import { CornerUpRight, FileImage, GripVertical, Monitor, Pencil, SquareTerminal, Trash2 } from "lucide-react"; +import { FileTypeIcon } from "../components/file-type-icon.js"; +import "./agent-queued-message-list.css"; import { OverflowTooltipText } from "../components/overflow-tooltip-text.js"; import { Tooltip } from "../components/tooltip.js"; +import { useTranslation } from "../i18n/use-translation.js"; import { AgentQueueChannelIcon, agentChannelDisplay, @@ -26,6 +29,7 @@ export interface AgentQueuedMessageListProps { onRemove: (clientRequestId: string) => void; onEdit: (clientRequestId: string) => void; onSteer: (clientRequestId: string) => void; + onReorder?: (orderedIds: string[]) => void; } export type QueuedComposerEditDecision = "restore" | "blocked" | "ignore"; @@ -34,9 +38,17 @@ export function canEditQueuedMessage( item: Pick ): boolean { return item.source.kind === "gui" - && item.media.length === 0 - && item.content.trim().length > 0 - && !item.content.trimStart().startsWith("/"); + && (item.content.trim().length > 0 || item.media.length > 0) + && !item.content.trimStart().startsWith("/") + && item.media.every((media) => Boolean(media.path)); +} + +export function reorderQueuedMessageIds(ids: readonly string[], sourceId: string, targetId: string, after: boolean): string[] | null { + if (sourceId === targetId || !ids.includes(sourceId) || !ids.includes(targetId)) return null; + const next = ids.filter((id) => id !== sourceId); + const target = next.indexOf(targetId); + next.splice(target + (after ? 1 : 0), 0, sourceId); + return next.every((id, index) => id === ids[index]) ? null : next; } export function queuedComposerEditDecision(input: { @@ -87,8 +99,26 @@ function queuedMessageLabel( } export function AgentQueuedMessageList(props: AgentQueuedMessageListProps) { + const { t } = useTranslation(); const listRef = useRef(null); const previousCountRef = useRef(props.items.length); + const [draggedId, setDraggedId] = useState(null); + const [dropTarget, setDropTarget] = useState<{ id: string; after: boolean } | null>(null); + const canReorder = Boolean(props.onReorder) && props.items.length > 1 + && props.items.every((item) => item.status === "queued"); + + const reorder = (sourceId: string, targetId: string, after: boolean) => { + const next = reorderQueuedMessageIds(props.items.map((item) => item.clientRequestId), sourceId, targetId, after); + if (next) props.onReorder?.(next); + }; + + const onDragOver = (event: DragEvent, id: string) => { + if (!canReorder || !draggedId || draggedId === id) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + const rect = event.currentTarget.getBoundingClientRect(); + setDropTarget({ id, after: event.clientY > rect.top + rect.height / 2 }); + }; useEffect(() => { if (props.items.length > previousCountRef.current) { @@ -113,7 +143,34 @@ export function AgentQueuedMessageList(props: AgentQueuedMessageListProps) { const showSource = item.source.kind !== "gui"; const sourceLabel = queueSourceLabel(item, props.sourceLabels); return ( -
  • +
  • { + if (!canReorder) { event.preventDefault(); return; } + setDraggedId(item.clientRequestId); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", item.clientRequestId); + }} + onDragOver={(event) => onDragOver(event, item.clientRequestId)} + onDrop={(event) => { + if (!canReorder || !draggedId || !dropTarget) return; + event.preventDefault(); + reorder(draggedId, dropTarget.id, dropTarget.after); + setDraggedId(null); + setDropTarget(null); + }} + onDragEnd={() => { setDraggedId(null); setDropTarget(null); }}> + {canReorder ? + : } {showSource ? ( ) : null} + {item.media.length ? : null}
    {showSteer ? ( @@ -136,7 +194,7 @@ export function AgentQueuedMessageList(props: AgentQueuedMessageListProps) { disabled={controlPending || !props.canSteer} onClick={() => props.onSteer(item.clientRequestId)} > - +
    @@ -173,53 +231,13 @@ export function AgentQueuedMessageList(props: AgentQueuedMessageListProps) { ); } -function QueueIcon(props: { name: string; children: ReactNode }) { - return ( - - ); -} - -function QueueSteerIcon() { - return ( - - - - ); -} - -function QueueEditIcon() { - return ( - - - - - - - - ); -} - -function QueueRemoveIcon() { - return ( - - - - - - - - ); +function QueueMediaPreview({ item, label }: { item: AgentQueuedMessage; label: string }) { + const first = item.media.find((media) => media.kind === "image") ?? item.media[0]!; + return + {first.kind === "image" && first.url + ? + : first.kind === "image" ? ; } diff --git a/App/frontend/desktop/src/pages/agent-stall-shimmer.ts b/App/frontend/desktop/src/pages/agent-stall-shimmer.ts new file mode 100644 index 000000000..c89c520cc --- /dev/null +++ b/App/frontend/desktop/src/pages/agent-stall-shimmer.ts @@ -0,0 +1,209 @@ +import { useEffect, useState, type RefObject } from "react"; +import type { AgentChatMessage } from "../state/agent-chat-slice.js"; + +/** How long the thread may go without new output before its last line shimmers. */ +export const AGENT_STALL_SHIMMER_DELAY_MS = 3000; + +export const AGENT_STALL_SHIMMER_CLASS = "agent-stall-shimmer"; + +/** + * Every element that reads as a line (or a block of lines) of Agent output. + * A folded panel contributes only its header: its body is either not + * rendered or sits in a closed `
    `, which `isLineVisible` rejects, + * so the header row becomes the last line. + */ +const OUTPUT_LINE_SELECTOR = [ + ".agent-message-content__p", + ".agent-message-content li", + ".agent-message-content h1", + ".agent-message-content h2", + ".agent-message-content h3", + ".agent-message-content h4", + ".agent-message-content h5", + ".agent-message-content h6", + ".agent-message-content__pre", + ".agent-message-content__th", + ".agent-message-content__td", + ".agent-activity-cluster__toggle > span", + ".agent-reasoning-panel__toggle > span", + ".agent-narration-block__label", + ".agent-narration-block__preview", + ".agent-activity-segment__label", + ".agent-activity-timeline-item__line", + ".agent-activity-timeline-item__error", + ".agent-thinking-placeholder > span:last-child", + ".agent-retry-wait-line__label", + ".agent-context-compaction-divider__label" +].join(", "); + +const EXCLUDED_SELECTOR = ".agent-user-turn, .sr-only, .agent-message-copy-cluster, .plugin-capability-region"; + +/** Lines that already animate while their own process runs. */ +const SELF_ANIMATED_SELECTOR = ".agent-retry-wait-line--running, .agent-context-compaction-divider--running"; + +/** + * Changes whenever the Agent produces something the user can read: answer + * text, reasoning, or a new tool or file row. Equal signatures mean nothing + * new appeared. + */ +export function agentOutputSignature(messages: readonly AgentChatMessage[]): string { + let size = 0; + for (const message of messages) { + size += message.content.length + + (message.reasoning?.length ?? 0) + + (message.toolEvents?.length ?? 0) + + (message.fileEdits?.length ?? 0) + + (message.traces?.length ?? 0); + } + return `${messages.length}:${size}`; +} + +/** + * The bottom line of the current turn as the user sees it, or null when the + * turn has shown nothing yet. + */ +export function findLastOutputLine(container: HTMLElement): HTMLElement | null { + const userTurns = container.querySelectorAll(".agent-user-turn"); + const lastUserTurn = userTurns[userTurns.length - 1] ?? null; + const candidates = container.querySelectorAll(OUTPUT_LINE_SELECTOR); + for (let index = candidates.length - 1; index >= 0; index -= 1) { + const candidate = candidates[index]!; + // Anything above the latest question belongs to an earlier answer. + if (lastUserTurn && !(lastUserTurn.compareDocumentPosition(candidate) & Node.DOCUMENT_POSITION_FOLLOWING)) return null; + if (isLineVisible(candidate)) return candidate; + } + return null; +} + +function isLineVisible(element: HTMLElement): boolean { + if (!element.textContent?.trim() || element.closest(EXCLUDED_SELECTOR)) return false; + for (let details = element.closest("details"); details; details = details.parentElement?.closest("details") ?? null) { + if (!details.open && !isInOwnSummary(element, details)) return false; + } + return typeof element.checkVisibility !== "function" || element.checkVisibility(); +} + +function isInOwnSummary(element: HTMLElement, details: HTMLDetailsElement): boolean { + for (let summary = element.closest("summary"); summary; summary = summary.parentElement?.closest("summary") ?? null) { + if (summary.parentElement === details) return true; + } + return false; +} + +/** Where the last rendered line sits inside the element's background area. */ +interface LineBox { + top: number; + left: number; + right: number; + height: number; +} + +function measureLastLine(element: HTMLElement): LineBox | null { + const style = window.getComputedStyle(element); + if (style.display === "inline") { + // An inline element paints one background across its line fragments laid + // end to end, so its last line is the tail of that strip. + const fragments = Array.from(element.getClientRects()).filter((rect) => rect.width > 0); + const last = fragments[fragments.length - 1]; + if (!last) return null; + const strip = fragments.reduce((total, rect) => total + rect.width, 0); + return { top: 0, left: strip - last.width, right: strip, height: last.height }; + } + const range = document.createRange(); + range.selectNodeContents(element); + const rects = Array.from(range.getClientRects()).filter((rect) => rect.width > 0 && rect.height > 0); + range.detach(); + if (!rects.length) return null; + const bottom = Math.max(...rects.map((rect) => rect.bottom)); + const lastLine = rects.filter((rect) => rect.bottom > bottom - rect.height / 2); + const box = element.getBoundingClientRect(); + const originX = box.left + element.clientLeft; + const originY = box.top + element.clientTop; + const top = Math.min(...lastLine.map((rect) => rect.top)); + return { + top: top - originY, + left: Math.max(0, Math.min(...lastLine.map((rect) => rect.left)) - originX), + // Truncated rows report their full text width; the band stops at the edge. + right: Math.min(element.clientWidth || Infinity, Math.max(...lastLine.map((rect) => rect.right)) - originX), + height: bottom - top + }; +} + +function markLine(element: HTMLElement): void { + if (element.closest(SELF_ANIMATED_SELECTOR)) return; + const line = measureLastLine(element); + if (!line || line.right <= line.left) return; + const band = Math.round(Math.min(180, Math.max(48, (line.right - line.left) * 0.5))); + const sweepDistance = line.right - line.left + band; + const SWEEP_SPEED_PX_PER_SEC = 300; + const PAUSE_SEC = 0.8; + const sweepDuration = sweepDistance / SWEEP_SPEED_PX_PER_SEC; + const totalDuration = sweepDuration + PAUSE_SEC; + const sweepFraction = sweepDuration / totalDuration; + const timing = `linear(0, 1 ${(sweepFraction * 100).toFixed(1)}%, 1)`; + const properties: Record = { + "--agent-stall-ink": window.getComputedStyle(element).color, + "--agent-stall-band": `${band}px`, + "--agent-stall-y": `${Math.floor(line.top)}px`, + "--agent-stall-height": `${Math.ceil(line.height)}px`, + "--agent-stall-from": `${Math.floor(line.left - band)}px`, + "--agent-stall-to": `${Math.ceil(line.right)}px` + }; + for (const [name, value] of Object.entries(properties)) element.style.setProperty(name, value); + element.style.animation = `agent-stall-sweep ${totalDuration.toFixed(2)}s ${timing} infinite`; + element.classList.add(AGENT_STALL_SHIMMER_CLASS); +} + +function unmarkLine(element: HTMLElement): void { + element.classList.remove(AGENT_STALL_SHIMMER_CLASS); + element.style.animation = ""; + for (const name of ["--agent-stall-ink", "--agent-stall-band", "--agent-stall-y", "--agent-stall-height", "--agent-stall-from", "--agent-stall-to"]) { + element.style.removeProperty(name); + } +} + +/** + * Sweeps a highlight across the thread's bottom line once the Agent has gone + * `AGENT_STALL_SHIMMER_DELAY_MS` without new output, so a long tool call or + * a slow model reads as "still working" rather than "stuck". New output + * clears it and restarts the wait. + * + * The line is found in the DOM rather than from the messages because what + * counts as the last line depends on which panels the user has folded. + */ +export function useStalledOutputShimmer(containerRef: RefObject, active: boolean, signature: string): void { + const [stalled, setStalled] = useState(false); + + useEffect(() => { + setStalled(false); + if (!active) return undefined; + const timer = window.setTimeout(() => setStalled(true), AGENT_STALL_SHIMMER_DELAY_MS); + return () => window.clearTimeout(timer); + }, [active, signature]); + + useEffect(() => { + const container = containerRef.current; + if (!stalled || !container) return undefined; + let marked: HTMLElement | null = null; + // Opening or folding a panel changes which line is last, and a React + // re-render can rewrite the marked element's class; both land here. + const sync = (remeasure = false) => { + const next = findLastOutputLine(container); + if (!remeasure && next === marked && (!next || next.classList.contains(AGENT_STALL_SHIMMER_CLASS))) return; + if (marked) unmarkLine(marked); + marked = next; + if (marked) markLine(marked); + }; + sync(); + const mutations = typeof MutationObserver === "undefined" ? null : new MutationObserver(() => sync()); + mutations?.observe(container, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["open", "class"] }); + // A narrower column rewraps the text, moving the last line. + const resizes = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => sync(true)); + resizes?.observe(container); + return () => { + mutations?.disconnect(); + resizes?.disconnect(); + if (marked) unmarkLine(marked); + }; + }, [containerRef, stalled]); +} diff --git a/App/frontend/desktop/src/pages/agent-task-plan-bar.css b/App/frontend/desktop/src/pages/agent-task-plan-bar.css new file mode 100644 index 000000000..269f6ac7a --- /dev/null +++ b/App/frontend/desktop/src/pages/agent-task-plan-bar.css @@ -0,0 +1,186 @@ +.agent-task-plan-bar { + overflow: hidden; + margin-bottom: 0.5rem; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 35%, transparent); + border-radius: 0.75rem; + background: color-mix(in srgb, var(--color-background-paper) 96%, transparent); +} +.agent-task-plan-bar--blocked { + border-color: color-mix(in srgb, var(--color-status-error) 25%, transparent); +} +.agent-task-plan-bar--embedded { + overflow: visible; + margin: 0; + border: 0; + border-radius: 0; + background: transparent; +} +.agent-task-plan-bar--embedded .agent-task-plan-bar__header { + padding: 2px 0; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); +} +.agent-task-plan-bar--embedded .agent-task-plan-bar__header:hover { + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); +} +.agent-task-plan-bar--embedded .agent-task-plan-bar__heading > svg { + width: 14px; + height: 14px; + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); +} +.agent-task-plan-bar--embedded .agent-task-plan-bar__title { + color: inherit; + font-size: 14px; + font-weight: 400; + line-height: 24px; +} +.agent-task-plan-bar--embedded .agent-task-plan-bar__items { + margin: 6px 0 4px; + border: 0; + border-radius: 10px; + background: color-mix(in srgb, var(--color-canvas-oat) 75%, var(--color-background-paper)); + padding: 10px 12px; +} +.agent-task-plan-bar--embedded .agent-task-plan-bar__item--blocked { + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); +} +.agent-task-plan-bar__header { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border: 0; + background: transparent; + padding: 0.55rem 0.625rem; + color: var(--color-text-ink); + text-align: left; + cursor: pointer; +} +.agent-task-plan-bar__header:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 48%, transparent); +} +.agent-task-plan-bar__header:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-action-sky) 36%, transparent); + outline-offset: -2px; +} +.agent-task-plan-bar__heading, +.agent-task-plan-bar__summary { + display: flex; + min-width: 0; + align-items: center; +} +.agent-task-plan-bar__heading { + flex: 1 1 auto; + gap: 0.5rem; +} +.agent-task-plan-bar__heading > svg { + width: 16px; + height: 16px; + flex: 0 0 auto; + color: var(--color-action-sky); +} +.agent-task-plan-bar__title { + overflow: hidden; + font-size: 14px; + font-weight: 600; + line-height: 22px; + text-overflow: ellipsis; + white-space: nowrap; +} +.agent-task-plan-bar__summary { + flex: 0 0 auto; + gap: 0.45rem; +} +.agent-task-plan-bar__status { + border-radius: 9999px; + background: color-mix(in srgb, var(--color-canvas-oat) 80%, transparent); + padding: 0.16rem 0.48rem; + font-size: 0.68rem; + font-weight: 600; + line-height: 1rem; + white-space: nowrap; +} +.agent-task-plan-bar__status--active, +.agent-task-plan-bar__status--completed { + color: var(--color-action-sky); +} +.agent-task-plan-bar__status--paused { + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +} +.agent-task-plan-bar__status--blocked { + color: var(--color-status-error); +} +.agent-task-plan-bar__progress { + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + font-size: 0.72rem; + white-space: nowrap; +} +.agent-task-plan-bar__chevron { + width: 15px; + height: 15px; + color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); + transition: transform var(--duration-fast) ease; +} +.agent-task-plan-bar__header[aria-expanded="false"] .agent-task-plan-bar__chevron { + transform: rotate(-90deg); +} +.agent-task-plan-bar__items { + display: grid; + gap: 0.35rem; + margin: 0; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 25%, transparent); + padding: 0.55rem 0.75rem 0.65rem; + list-style: none; +} +.agent-task-plan-bar__item { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 0.5rem; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + font-size: 14px; + line-height: 22px; +} +.agent-task-plan-bar__item > svg { + width: 16px; + height: 16px; + margin-top: 3px; + flex: 0 0 auto; +} +.agent-task-plan-bar__item--pending > svg { + color: color-mix(in srgb, var(--color-text-ink) 30%, transparent); +} +.agent-task-plan-bar__item--in_progress { + color: var(--color-text-ink); + font-weight: 500; +} +.agent-task-plan-bar__item--in_progress > svg { + color: var(--color-action-sky); + animation: agent-task-plan-spin 1.2s linear infinite; +} +.agent-task-plan-bar__item--completed { + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); +} +.agent-task-plan-bar__item--completed > svg { + color: var(--color-action-sky); +} +.agent-task-plan-bar__item--blocked, +.agent-task-plan-bar__item--blocked > svg { + color: var(--color-status-error); +} +@keyframes agent-task-plan-spin { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .agent-task-plan-bar__item--in_progress > svg { + animation: none; + } + + .agent-task-plan-bar__chevron { + transition: none; + } +} diff --git a/App/frontend/desktop/src/pages/agent-task-plan-bar.tsx b/App/frontend/desktop/src/pages/agent-task-plan-bar.tsx new file mode 100644 index 000000000..00ce499d7 --- /dev/null +++ b/App/frontend/desktop/src/pages/agent-task-plan-bar.tsx @@ -0,0 +1,93 @@ +import "./agent-task-plan-bar.css"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + Circle, + ListChecks, + LoaderCircle, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import type { + AgentTaskPlanItemStatus, + AgentTaskPlanState, +} from "../api/memmy-agent-client.js"; +import { useTranslation } from "../i18n/use-translation.js"; + +export interface AgentTaskPlanBarProps { + plan: AgentTaskPlanState; + /** The enclosing turn process supplies the disclosure and status. */ + embeddedInProcess?: boolean; + /** Keep the checklist open while its owning turn is running. */ + runActive?: boolean; +} + +export function AgentTaskPlanBar({ plan, embeddedInProcess = false, runActive = false }: AgentTaskPlanBarProps) { + const { t } = useTranslation(); + const autoCollapsed = plan.status !== "active" || !runActive; + const [collapsed, setCollapsed] = useState(autoCollapsed); + + useEffect(() => { + setCollapsed(autoCollapsed); + }, [autoCollapsed, plan.plan_id]); + + if (!plan.plan_id || !plan.status || plan.items.length === 0) return null; + + const completed = plan.items.filter((item) => item.status === "completed").length; + const progressLabel = t("home.taskPlan.progress", { + completed, + total: plan.items.length, + }); + const visibleStatus = plan.status === "active" && !runActive ? "paused" : plan.status; + const header = <> + + + + {!embeddedInProcess ? + {t(`home.taskPlan.status.${visibleStatus}`)} + : null} + {progressLabel} + + ; + + return ( +
    + + + {!collapsed ? ( +
      + {plan.items.map((item) => { + const status = !runActive && item.status === "in_progress" ? "pending" : item.status; + return ( +
    1. + + {item.content} +
    2. + ); + })} +
    + ) : null} +
    + ); +} + +function TaskPlanItemIcon({ status }: { status: AgentTaskPlanItemStatus }) { + if (status === "completed") return