diff --git a/.github/release-notes/v1.2.401.md b/.github/release-notes/v1.2.401.md new file mode 100644 index 000000000..0dfeacb2c --- /dev/null +++ b/.github/release-notes/v1.2.401.md @@ -0,0 +1,27 @@ +# Memmy v1.2.401 + +## Highlights + +- Fixed macOS in-app updates being rejected during CPU architecture validation. +- Host-managed Agent file tools now use approvals for file access and keep runtime state separate from unapproved projects. +- Corrected the seven-day skill-use metric to count recalls and invocations of activated skills instead of recently updated skill records. + +## Agent and Desktop improvements + +- Hide pagination controls when the User Memories list is empty. +- Fixed packaged Memory startup by including the `ignore` dependency. +- Updated Electron and SQLite runtime dependencies, aligned Memory packaging with SQLite 13 N-API prebuilds, and pinned standalone builds to Node.js 24.22.1. +- Retry transient HTTP 503 responses while the Linux systemd Gateway is starting. +- Improve Windows installer build reliability with prepared native dependencies, isolated edition outputs, and bounded recovery for transient signing and file-lock errors. +- Preserve the Developer ID requirement when staging macOS updates and compare update version characters consistently. +- Apply managed approvals to directory traversal, file search, edits, and PDF/DOCX text reads; prevent project-controlled bootstrap files and plugins from being read before approval. + +## Upgrade notes + +- Applications embedding the Agent in managed mode must migrate to `AGENT_MANAGED_EXECUTION_REQUIRED`, `AGENT_RUNTIME_TOKEN`, and `AGENT_MACOS_SANDBOX`, the `x-agent-runtime-token` request header, and the `managed_execution` request field. The former `MEMMATE_*` settings, header, and `memmate_permission` field are no longer recognized. +- Managed integrations must use `authorize_read_path` for descendant-path authorization and the health fields `permissionProtocol`, `managedExecutionProtocol`, `managedExecutionRequired`, and `macosSandbox`. Update the embedding application together with the Agent and verify the protocol before rollout. Standalone Agent operation does not require these managed integration settings. +- The Linux CLI installer requires Node.js 22.23.4+ within 22.x, 24.22.1+ within 24.x, or 26.12.1+ within 26.x. Other major versions are rejected. + +- Memmy Desktop and Agent are version `1.2.401`; the bundled Memory service, viewer, and CLI remain independently versioned at `2.1.7`. +- The skill-use total reports usage events, not the number of distinct skills. Updating a skill without recalling or invoking it no longer increases this total. +- Installation, in-place upgrade, signed-package validation, and publication remain separate release checks. diff --git a/.github/workflows/linux-cli-installer.yml b/.github/workflows/linux-cli-installer.yml index 6d9ed48cb..f2747896d 100644 --- a/.github/workflows/linux-cli-installer.yml +++ b/.github/workflows/linux-cli-installer.yml @@ -61,7 +61,7 @@ jobs: ref: ${{ github.event_name == 'release' && format('refs/tags/{0}', github.event.release.tag_name) || github.sha }} - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 22.23.4 cache: npm - uses: actions/setup-go@v5 with: @@ -124,7 +124,7 @@ jobs: path: release-assets - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 22.23.4 - name: Install twice and exercise the CLI env: VERSION: ${{ needs.build.outputs.version }} diff --git a/App/backend/local-api-contracts/package.json b/App/backend/local-api-contracts/package.json index d33a62b9f..7781c8149 100644 --- a/App/backend/local-api-contracts/package.json +++ b/App/backend/local-api-contracts/package.json @@ -14,7 +14,7 @@ }, "scripts": { "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json", - "test": "npm run typecheck && vitest run tests/desktop-runtime-manifest.test.ts tests/language.test.ts tests/model-catalog-resolver.test.ts tests/model-thinking-levels.test.ts", + "test": "npm run typecheck && vitest run --silent=true tests/desktop-runtime-manifest.test.ts tests/language.test.ts tests/model-catalog-resolver.test.ts tests/model-thinking-levels.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/App/backend/package.json b/App/backend/package.json index 562e02d1c..ca4b0f1a7 100644 --- a/App/backend/package.json +++ b/App/backend/package.json @@ -17,7 +17,7 @@ "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", "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", + "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 --reporter=dot --silent=true", "test:agent-adapter:coverage": "npm run build -w @memmy/local-api-contracts && vitest run src/adapters/outbound/agent-adapter/tests --coverage", "db:migrate": "tsx src/infrastructure/app-state-store/cli/migrate.ts" }, diff --git a/App/backend/src/project-version.ts b/App/backend/src/project-version.ts index 6852134c5..4291df6fd 100644 --- a/App/backend/src/project-version.ts +++ b/App/backend/src/project-version.ts @@ -1,2 +1,2 @@ /** Generated from the root package.json by scripts/sync-project-version.mjs. */ -export const MEMMY_VERSION = "1.2.4"; +export const MEMMY_VERSION = "1.2.401"; diff --git a/App/backend/src/tests/project-version.test.ts b/App/backend/src/tests/project-version.test.ts index 1034d5d24..7a3eb9a74 100644 --- a/App/backend/src/tests/project-version.test.ts +++ b/App/backend/src/tests/project-version.test.ts @@ -11,6 +11,7 @@ describe("project version", () => { ); expect(MEMMY_VERSION).toBe(rootManifest.version); + expect(MEMMY_VERSION).toBe("1.2.401"); expect(MEMMY_VERSION).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/); expect(readFileSync(resolve(repoRoot, "App/backend/src/project-version.ts"), "utf8")) .toContain(`MEMMY_VERSION = ${JSON.stringify(rootManifest.version)}`); diff --git a/App/frontend/desktop/package.json b/App/frontend/desktop/package.json index d976a93c9..928316119 100644 --- a/App/frontend/desktop/package.json +++ b/App/frontend/desktop/package.json @@ -7,7 +7,7 @@ "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 && vitest run --reporter=dot --silent=true" }, "dependencies": { "@memmy/desktop-interface": "0.0.0", diff --git a/App/frontend/desktop/src/app/browser-update.ts b/App/frontend/desktop/src/app/browser-update.ts index bde121b03..75ca7ce37 100644 --- a/App/frontend/desktop/src/app/browser-update.ts +++ b/App/frontend/desktop/src/app/browser-update.ts @@ -89,18 +89,9 @@ export function openUpdateUrlInBrowser(downloadUrl: string): void { window.open(downloadUrl, "_blank", "noopener,noreferrer"); } -/** Compares numeric version segments. */ +/** Match Desktop's character ordering, including release suffixes. */ export function compareUpdateVersions(left: string, right: string): number { - const leftParts = extractUpdateVersionSegments(left); - const rightParts = extractUpdateVersionSegments(right); - const length = Math.max(leftParts.length, rightParts.length); - for (let index = 0; index < length; index += 1) { - const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); - if (diff !== 0) { - return diff; - } - } - return 0; + return left === right ? 0 : left < right ? -1 : 1; } function resolveBrowserUpdateEdition(): "cn" | "intl" { @@ -176,10 +167,6 @@ function buildBrowserUpdateDownloadKeys(): string[] { return [resolveBrowserUpdatePlatformType()]; } -function extractUpdateVersionSegments(version: string): number[] { - return (version.match(/\d+/gu) ?? []).map((part) => Number(part)); -} - function readUpdateManifestString(value: unknown, key: string): string | null { if (!isUpdateManifestRecord(value)) { return null; diff --git a/App/frontend/desktop/src/app/tests/browser-update.test.ts b/App/frontend/desktop/src/app/tests/browser-update.test.ts index d2585cf00..615d0dc2a 100644 --- a/App/frontend/desktop/src/app/tests/browser-update.test.ts +++ b/App/frontend/desktop/src/app/tests/browser-update.test.ts @@ -56,8 +56,25 @@ describe("browser update fallback", () => { currentVersion: "1.4.2", latestVersion: "1.4.2" }); - expect(compareUpdateVersions("1.10.0", "1.9.9")).toBeGreaterThan(0); + expect(compareUpdateVersions("1.10.0", "1.9.9")).toBeLessThan(0); expect(() => parseBrowserUpdateManifest({ code: 500, data: {} }, "1.4.2")) .toThrow("update manifest response code is not ok"); }); + + it.each([ + ["1.2.4", "1.2.401"], + ["1.2.401", "1.2.402"], + ["1.2.402", "1.2.5"], + ["1.2.5", "1.2.6"], + ["1.2.6", "1.3.0"], + ["1.2.10", "1.2.9"], + ])("offers %s to %s without offering the reverse update", (current, latest) => { + expect(parseBrowserUpdateManifest({ code: 0, data: { version: latest } }, current).status).toBe("available"); + expect(parseBrowserUpdateManifest({ code: 0, data: { version: current } }, latest).status).toBe("latest"); + }); + + it("uses character ordering for the minimum supported version", () => { + expect(parseBrowserUpdateManifest({ code: 0, data: { version: "1.2.6", minSupportedVersion: "1.2.5" } }, "1.2.401")) + .toMatchObject({ status: "available", force: true }); + }); }); diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 8d8143c4d..8a5488a47 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -1500,7 +1500,7 @@ export const zhCNMessages = { "memory.analytics.toolAverageLatency": "工具平均耗时", "memory.analytics.toolP95Latency": "工具 P95 耗时", "memory.analytics.recallEvents": "{count} 次召回事件", - "memory.analytics.recentlyUsedSkills": "近 7 天使用 {count} 个技能", + "memory.analytics.recentlyUsedSkills": "近 7 天使用技能 {count} 次", "memory.analytics.toolAverageLatencyHint": "按全部工具调用统计", "memory.analytics.slowCallWatch": "用于观察慢调用", "memory.analytics.activeMemories": "活跃记忆", @@ -3728,7 +3728,7 @@ export const enUSMessages: Record = { "memory.analytics.toolAverageLatency": "Avg tool latency", "memory.analytics.toolP95Latency": "P95 tool latency", "memory.analytics.recallEvents": "{count} recall events", - "memory.analytics.recentlyUsedSkills": "{count} skills used in the last 7 days", + "memory.analytics.recentlyUsedSkills": "{count} skill uses in the last 7 days", "memory.analytics.toolAverageLatencyHint": "Across all tool calls", "memory.analytics.slowCallWatch": "Slow-call watch", "memory.analytics.activeMemories": "Active memories", diff --git a/App/frontend/desktop/src/pages/memory/tests/analytics-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/analytics-sub-page.test.tsx index e25f44fec..6437e24b2 100644 --- a/App/frontend/desktop/src/pages/memory/tests/analytics-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/analytics-sub-page.test.tsx @@ -25,6 +25,7 @@ describe("AnalyticsSubPage", () => { expect(html).toContain('data-icon="bar-chart-3"'); expect(html).toContain("平均召回得分"); expect(html).toContain("活跃技能数量"); + expect(html).toContain("近 7 天使用技能 18 次"); expect(html).toContain("工具平均耗时"); expect(html).toContain("工具 P95 耗时"); expect(html).toContain("repeat(auto-fit, minmax(min(100%, 144px), 1fr))"); diff --git a/App/frontend/desktop/src/pages/memory/tests/user-memories-sub-page.interaction.test.tsx b/App/frontend/desktop/src/pages/memory/tests/user-memories-sub-page.interaction.test.tsx index 5a369b4cc..37515f422 100644 --- a/App/frontend/desktop/src/pages/memory/tests/user-memories-sub-page.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/user-memories-sub-page.interaction.test.tsx @@ -21,6 +21,23 @@ describe("UserMemoriesSubPage interaction", () => { container.remove(); }); + it("does not render pagination when there are no user memories", async () => { + const client = createMemoryRuntimeClientStub({ + listPanelItems: vi.fn(async () => panelItemsOutput([])) + }); + + await act(async () => { + root.render( + + + + ); + }); + + expect(container.textContent).toContain("暂无用户记忆"); + expect(container.querySelector(".memory-pagination")).toBeNull(); + }); + it("loads the UserMemory layer and deletes the selected record", async () => { const item = { id: "user_memory_1", diff --git a/App/frontend/desktop/src/pages/memory/user-memories-sub-page.tsx b/App/frontend/desktop/src/pages/memory/user-memories-sub-page.tsx index b28c6d993..1b63cb3bc 100644 --- a/App/frontend/desktop/src/pages/memory/user-memories-sub-page.tsx +++ b/App/frontend/desktop/src/pages/memory/user-memories-sub-page.tsx @@ -126,10 +126,12 @@ export function UserMemoriesSubPage(props: UserMemoriesSubPageProps) { ))} - void refresh(nextPage)} - /> + {state.data.items.length > 0 ? ( + void refresh(nextPage)} + /> + ) : null} ) : null} diff --git a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx index 101c32995..5fec55fe9 100644 --- a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx @@ -140,16 +140,29 @@ describe("BYOK displayed, tested and saved credentials", () => { input.dispatchEvent(new Event("input", { bubbles: true })); }); } + async function saveAndWait(button: HTMLButtonElement, navigates = false) { + const saveIndex = server.saveCatalog.mock.results.length; + const writes = server.write.mock.calls.length; + const dispatchIndex = mocks.dispatch.mock.calls.length; + await click(button); + await vi.waitFor(() => expect(server.saveCatalog.mock.results.length).toBeGreaterThan(saveIndex)); + // A new preset is created before its assignment is saved. Wait for this + // complete catalog mutation, rather than the first backend write or an old navigation. + await act(async () => { await server.saveCatalog.mock.results[saveIndex]!.value; }); + expect(server.write.mock.calls.length).toBeGreaterThan(writes); + if (navigates) { + await vi.waitFor(() => expect(mocks.dispatch.mock.calls.slice(dispatchIndex).map(([action]) => action)) + .toContainEqual(expect.objectContaining({ type: "navigation/changed" }))); + } + } + async function saveOnboarding() { await saveAndWait(next(), true); } async function testAndSave(scenario: Scenario) { const count = server.probe.mock.calls.length; await click(test(scenario.index)); await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(count + 1)); await vi.waitFor(() => expect(test(scenario.index).textContent).toBe("apiKey.testSuccess")); const tested = server.probe.mock.calls.at(-1)![0]; - const writes = server.write.mock.calls.length; - await click(next()); - await vi.waitFor(() => expect(server.write.mock.calls.length).toBeGreaterThan(writes)); - await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "navigation/changed" }))); + await saveOnboarding(); expect(await server.key(scenario.role)).toBe(tested.apiKey); return tested; } @@ -250,8 +263,7 @@ describe("BYOK displayed, tested and saved credentials", () => { it.each([0, 1])("role %i: enabling reuse saves the inherited credential instead of the independent credential", async (index) => { await render(); await click([...container.querySelectorAll('button[role="checkbox"]')][index]!); - await click(next()); - await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "navigation/changed" }))); + await saveOnboarding(); expect(await server.key(index === 0 ? "memory_summary" : "memory_evolution")).toBe(index === 0 ? "sk-team-1111" : "sk-personal-1111"); }); @@ -348,8 +360,7 @@ describe("BYOK displayed, tested and saved credentials", () => { await vi.waitFor(() => expect(container.textContent).toContain("assignment-write-failed")); expect(mocks.dispatch).not.toHaveBeenCalled(); const endpointsAfterCreate = (await server.read()).catalog!.providers.flatMap((provider) => provider.endpoints).length; - await click(next()); - await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "navigation/changed" }))); + await saveOnboarding(); expect(await server.key("agent")).toBe("sk-retry-1111"); expect((await server.read()).catalog!.providers.flatMap((provider) => provider.endpoints)).toHaveLength(endpointsAfterCreate); }); @@ -396,8 +407,7 @@ describe("BYOK displayed, tested and saved credentials", () => { await vi.waitFor(() => expect(document.body.textContent).toContain("settings.modelWorkspace.testSuccess")); expect(server.probe.mock.calls.at(-1)![0]).toMatchObject({ apiKey: scenario.expected, apiBase: "https://changed.example/v2" }); expect(settingsSave().disabled).toBe(false); - await click(settingsSave()); - await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + await saveAndWait(settingsSave()); await vi.waitFor(async () => { const config = await server.read(); expect(config.catalog!.providers.flatMap((p) => p.endpoints).find((e) => e.endpointId === oldId)?.apiBase).toBe("https://changed.example/v2"); @@ -429,8 +439,7 @@ describe("BYOK displayed, tested and saved credentials", () => { await vi.waitFor(() => expect(server.probe.mock.calls.length).toBeGreaterThan(beforeTest4)); await vi.waitFor(() => expect(document.body.textContent).toContain("settings.modelWorkspace.testSuccess")); expect(server.probe.mock.calls.at(-1)![0]).toMatchObject({ apiKey: "sk-personal-1111", modelId: "changed-model", apiBase: "https://provider.example/v1" }); - await click(settingsSave()); - await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + await saveAndWait(settingsSave()); expect(await server.key("agent")).toBe("sk-personal-1111"); }); @@ -534,9 +543,7 @@ describe("BYOK displayed, tested and saved credentials", () => { expect(test(scenario.index).textContent).toBe("apiKey.testSuccess"); expect(next().disabled).toBe(false); expect(server.write).not.toHaveBeenCalled(); - await click(next()); - await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); - await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "navigation/changed" }))); + await saveOnboarding(); expect(await server.key(scenario.role)).toBe("sk-after-reentry-9999"); }); @@ -557,8 +564,7 @@ describe("BYOK displayed, tested and saved credentials", () => { expect(test(scenario.index).textContent).toBe("apiKey.testSuccess"); await finishOld(false); expect(test(scenario.index).textContent).toBe("apiKey.testSuccess"); - await click(next()); - await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + await saveOnboarding(); expect(await server.key(scenario.role)).toBe("sk-new-onboarding-9999"); }); @@ -599,8 +605,7 @@ describe("BYOK displayed, tested and saved credentials", () => { await testAndSave(scenario); } else { expect(test(scenario.index).textContent).toBe("apiKey.testSuccess"); - await click(next()); - await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + await saveOnboarding(); } expect(await server.key(scenario.role)).toBe(order === "return-to-original" ? scenario.expected : "sk-new-onboarding-9999"); }); @@ -701,8 +706,7 @@ describe("BYOK displayed, tested and saved credentials", () => { await finish(); expect(test(index).textContent).toBe("apiKey.testSuccess"); expect(next().disabled).toBe(false); - await click(next()); - await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + await saveOnboarding(); }); it.each([0, 1])("role %i: editing the other independent card leaves this request valid", async (index) => { @@ -740,8 +744,7 @@ describe("BYOK displayed, tested and saved credentials", () => { } expect(test(1).textContent).toBe("apiKey.testSuccess"); expect(next().disabled).toBe(false); - await click(next()); - await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + await saveOnboarding(); expect(await server.key("memory_evolution")).toBe("sk-team-1111"); }); @@ -764,8 +767,7 @@ describe("BYOK displayed, tested and saved credentials", () => { await render(); await click(container.querySelectorAll('button[role="checkbox"]')[0]!); await click(container.querySelectorAll('button[role="checkbox"]')[1]!); - await click(next()); - await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "navigation/changed" }))); + await saveOnboarding(); expect(await server.key("memory_evolution")).toBe("sk-personal-1111"); expect(await server.key("memory_summary")).toBe("sk-personal-1111"); await render(null); diff --git a/App/memmy-agent/package-lock.json b/App/memmy-agent/package-lock.json index 847c5295e..a29e9b41c 100644 --- a/App/memmy-agent/package-lock.json +++ b/App/memmy-agent/package-lock.json @@ -1,12 +1,12 @@ { "name": "memmy-agent", - "version": "1.2.4", + "version": "1.2.401", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memmy-agent", - "version": "1.2.4", + "version": "1.2.401", "dependencies": { "@anthropic-ai/sdk": "^0.100.1", "@aws-sdk/client-bedrock-runtime": "^3.1061.0", @@ -27,7 +27,7 @@ "@types/ws": "^8.18.1", "@wecom/aibot-node-sdk": "^1.0.7", "@xmldom/xmldom": "^0.8.13", - "better-sqlite3": "^12.11.1", + "better-sqlite3": "^13.0.3", "botbuilder": "^4.23.3", "commander": "^14.0.2", "cron-parser": "^5.5.0", @@ -121,7 +121,7 @@ "name": "@memmy/migrations", "version": "0.0.0", "dependencies": { - "better-sqlite3": "^12.11.1", + "better-sqlite3": "^13.0.3", "proper-lockfile": "^4.1.2", "yaml": "^2.8.2" }, @@ -3463,17 +3463,15 @@ } }, "node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", - "hasInstallScript": true, + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "node-addon-api": "^8.0.0" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + "node": ">=22" } }, "node_modules/big-integer": { @@ -3498,15 +3496,6 @@ "node": "*" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4048,12 +4037,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/clean-git-ref": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", @@ -4476,15 +4459,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -4585,6 +4559,7 @@ }, "node_modules/detect-libc": { "version": "2.1.2", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5253,15 +5228,6 @@ "node": ">=8.3.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.3.0", "devOptional": true, @@ -5459,12 +5425,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/filename-reserved-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz", @@ -5764,12 +5724,6 @@ "node": ">= 0.4" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -6268,12 +6222,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/ink": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/ink/-/ink-6.8.0.tgz", @@ -8474,12 +8422,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -8500,12 +8442,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -8520,16 +8456,13 @@ "node": ">= 0.6" } }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, "engines": { - "node": ">=10" + "node": "^18 || ^20 || >= 21" } }, "node_modules/node-fetch": { @@ -12959,33 +12892,6 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -13124,16 +13030,6 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "dev": true, @@ -13212,30 +13108,6 @@ "node": ">= 0.10" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -14114,18 +13986,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -14333,18 +14193,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/turndown": { "version": "7.2.4", "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", diff --git a/App/memmy-agent/package.json b/App/memmy-agent/package.json index e2bb45a25..f70602070 100644 --- a/App/memmy-agent/package.json +++ b/App/memmy-agent/package.json @@ -1,6 +1,6 @@ { "name": "memmy-agent", - "version": "1.2.4", + "version": "1.2.401", "description": "TypeScript refactor of memmy's agent runtime.", "type": "module", "main": "./dist/index.js", @@ -19,7 +19,7 @@ "package:npm": "node scripts/build-npm-package.mjs", "pack:npm": "npm run package:npm && npm pack ./npm-packages/memmy-agent --pack-destination ./npm-packages", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run", + "test": "vitest run --maxWorkers=4 --reporter=dot --silent=true", "test:tui-cursor": "vitest run tests/entrypoints/cli/tui-cursor.test.ts", "test:watch": "vitest" }, @@ -42,7 +42,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/ws": "^8.18.1", "@wecom/aibot-node-sdk": "^1.0.7", - "better-sqlite3": "^12.11.1", + "better-sqlite3": "^13.0.3", "botbuilder": "^4.23.3", "commander": "^14.0.2", "cron-parser": "^5.5.0", diff --git a/App/memmy-agent/src/config/paths.ts b/App/memmy-agent/src/config/paths.ts index 6fd94f42d..123804c7b 100644 --- a/App/memmy-agent/src/config/paths.ts +++ b/App/memmy-agent/src/config/paths.ts @@ -45,7 +45,8 @@ export function getWebuiDir(): string { } export function getWorkspacePath(workspace?: string | null): string { - return ensureDir(expandHome(workspace || process.env.MEMMY_AGENT_WORKSPACE || "~/.memmy/workspace")); + const target = expandHome(workspace || process.env.MEMMY_AGENT_WORKSPACE || "~/.memmy/workspace"); + return process.env.AGENT_MANAGED_EXECUTION_REQUIRED === "1" ? target : ensureDir(target); } export function getStandaloneTaskWorkspacesDir(): string { @@ -64,3 +65,10 @@ export function getCliHistoryPath(): string { export function getBridgeInstallDir(): string { return path.join(os.homedir(), ".memmy", "bridge"); } + +/** Host-owned state must never be created or loaded from an unapproved project directory. */ +export function getWorkspaceStatePath(workspace: string): string { + if (process.env.AGENT_MANAGED_EXECUTION_REQUIRED !== "1") return workspace; + if (!path.isAbsolute(process.env.MEMMY_AGENT_DATA_DIR ?? "")) throw new Error("Managed runtime data directory is required"); + return getRuntimeSubdir("workspace"); +} diff --git a/App/memmy-agent/src/core/agent-runtime/context.ts b/App/memmy-agent/src/core/agent-runtime/context.ts index 8978cfe83..c8fa831d5 100644 --- a/App/memmy-agent/src/core/agent-runtime/context.ts +++ b/App/memmy-agent/src/core/agent-runtime/context.ts @@ -1,3 +1,4 @@ +import { getWorkspaceStatePath } from "../../config/paths.js"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -102,10 +103,10 @@ export class ContextBuilder { this.workspace = path.resolve(opts.workspace ?? process.cwd()); this.timezone = opts.timezone ?? null; this.fileMemoryEnabled = opts.fileMemoryEnabled === true; - this.memory = new MemoryStore(this.workspace, { + this.memory = new MemoryStore(getWorkspaceStatePath(this.workspace), { fileMemoryEnabled: this.fileMemoryEnabled, }); - this.skills = new SkillsLoader(this.workspace, null, opts.disabledSkills ?? null); + this.skills = new SkillsLoader(this.memory.workspace, null, opts.disabledSkills ?? null); } static buildRuntimeContext( @@ -140,7 +141,7 @@ export class ContextBuilder { const parts: string[] = []; for (const filename of ContextBuilder.BOOTSTRAP_FILES) { if (filename === "USER.md" && !this.fileMemoryEnabled) continue; - const file = path.join(this.workspace, filename); + const file = path.join(this.memory.workspace, filename); if (fs.existsSync(file)) parts.push(`## ${filename}\n\n${fs.readFileSync(file, "utf8")}`); } return parts.join("\n\n"); @@ -161,7 +162,7 @@ export class ContextBuilder { const runtime = `${platformName === "Darwin" ? "macOS" : platformName} ${os.arch()}, Node.js ${process.version}`; return renderTemplate("agent/identity.md", { workspacePath: sessionWorkspace, - customSkillsPath: path.join(this.workspace, "skills"), + customSkillsPath: path.join(this.memory.workspace, "skills"), runtime, platformPolicy: renderTemplate("agent/platform-policy.md", { system: platformName }), channel: channel ?? "", diff --git a/App/memmy-agent/src/core/agent-runtime/loop.ts b/App/memmy-agent/src/core/agent-runtime/loop.ts index 1ae2e51ee..00fadb521 100644 --- a/App/memmy-agent/src/core/agent-runtime/loop.ts +++ b/App/memmy-agent/src/core/agent-runtime/loop.ts @@ -15,7 +15,7 @@ import { import { CommandContext, CommandRouter } from "../../command/router.js"; import { registerBuiltinCommands } from "../../command/builtin.js"; import { Config, ModelPresetConfig } from "../../config/schema.js"; -import { getWorkspacePath } from "../../config/paths.js"; +import { getWorkspacePath, getWorkspaceStatePath } from "../../config/paths.js"; import { CONTEXT_SAFETY_BUFFER_TOKENS } from "../../token-budget.js"; import { CronService } from "../../cron/service.js"; import { makeProvider } from "../../providers/factory.js"; @@ -911,8 +911,9 @@ export class AgentLoop { const rawPresets = init.modelPresets ?? this.config.modelPresets; this.modelPresets = Object.fromEntries(Object.entries(rawPresets).map(([name, preset]) => [name, preset instanceof ModelPresetConfig ? preset : new ModelPresetConfig(preset)])); this.unifiedSession = init.unifiedSession ?? defaults.unifiedSession; + const stateWorkspace = getWorkspaceStatePath(this.workspace); this.sessions = init.sessionManager ?? new SessionManager( - init.sessionDir ?? path.join(this.workspace, "sessions"), + init.sessionDir ?? path.join(stateWorkspace, "sessions"), { legacyWebuiWorkspaceCwd: this.workspace }, ); this.goalRuntime = new GoalRuntime({ @@ -929,7 +930,7 @@ export class AgentLoop { ?? path.join(this.workspace, "sessions"); this.terminalTurnLock = new TerminalSessionTurnLock(terminalControlRoot); this.terminalRunControl = new TerminalRunControl(terminalControlRoot); - this.cronService = init.cronService ?? new CronService(path.join(this.workspace, "cron", "jobs.json")); + this.cronService = init.cronService ?? new CronService(path.join(stateWorkspace, "cron", "jobs.json")); this.sessionDagQueue = init.sessionDagQueue ?? this.createSessionDagQueue(); this.execSessionManager = new ExecSessionManager(); this.fileStateStore = new FileStateStore(); @@ -1150,7 +1151,7 @@ export class AgentLoop { } private projectReadonlySkillRoots(): readonly string[] { - const roots = [path.join(this.workspace, "skills"), BUILTIN_SKILLS_DIR] + const roots = [path.join(getWorkspaceStatePath(this.workspace), "skills"), BUILTIN_SKILLS_DIR] .flatMap((root) => { try { return fs.statSync(root).isDirectory() ? [fs.realpathSync(root)] : []; diff --git a/App/memmy-agent/src/core/agent-runtime/managed-execution.ts b/App/memmy-agent/src/core/agent-runtime/managed-execution.ts new file mode 100644 index 000000000..f984607a5 --- /dev/null +++ b/App/memmy-agent/src/core/agent-runtime/managed-execution.ts @@ -0,0 +1,145 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID, timingSafeEqual } from "node:crypto"; + +type ManagedTool = { name: string; description: string; inputSchema: Record }; +type ManagedExecutionScope = { tools: ManagedTool[]; url: string; token: string; active: boolean; signal: AbortSignal; onEvent?: (event: Record) => void }; +const scopes = new AsyncLocalStorage(); +const fileCalls = new AsyncLocalStorage(); + +export function withManagedFileCall(callId: string, run: () => T): T { + return fileCalls.run(callId, run); +} + +/** Traversal grants never cover descendants: check every path before returning or reading it. */ +export async function withAuthorizedReadPath(path: string, read: () => Promise): Promise { + if (!managedExecutionRequired()) return read(); + const parentCallId = fileCalls.getStore(); + if (!parentCallId) throw new Error("Managed runtime file access context is missing"); + const callId = randomUUID(); + emitManagedToolEvent({ type: "tool_call", callId, name: "read_file", input: { path } }); + try { + await executeManagedTool("authorize_read_path", { path }, undefined, callId, parentCallId); + const result = await read(); + const scope = scopes.getStore(); + if (!scope?.active || scope.signal.aborted) throw new Error("Managed runtime run has ended"); + emitManagedToolEvent({ type: "tool_result", callId, name: "read_file", ok: true, output: "File access completed" }); + return result; + } catch (error) { + emitManagedToolEvent({ type: "tool_result", callId, name: "read_file", ok: false, + output: `Error: ${error instanceof Error ? error.message : String(error)}` }); + throw error; + } +} + +class PathDeniedError extends Error {} + +/** One batch owns its skipped-path count; a denied root never becomes a partial success. */ +export class AuthorizedReadBatch { + deniedPaths = 0; + constructor(private readonly root: string) {} + + async read(path: string, read: () => Promise): Promise { + try { + return await withAuthorizedReadPath(path, read); + } catch (error) { + if (path === this.root || !(error instanceof PathDeniedError)) throw error; + this.deniedPaths += 1; + return undefined; + } + } + + format(result: string): string { + if (!this.deniedPaths) return result; + return `Partial results: skipped ${this.deniedPaths} denied path(s). The results below cover authorized paths only; counts are not the directory total. Do not retry denied paths. Other authorized files remain usable.\n\n${result}`; + } +} + +export function managedExecutionRequired(): boolean { + return process.env.AGENT_MANAGED_EXECUTION_REQUIRED === "1"; +} + +export function authenticateAgentRuntime(request: Request): boolean { + const expected = process.env.AGENT_RUNTIME_TOKEN ?? ""; + const actual = request.headers.get("x-agent-runtime-token") ?? ""; + const expectedBytes = Buffer.from(expected); + const actualBytes = Buffer.from(actual); + return expected.length >= 32 && actualBytes.length === expectedBytes.length + && timingSafeEqual(actualBytes, expectedBytes); +} + +export function createManagedExecutionScope(value: unknown, signal: AbortSignal): ManagedExecutionScope { + if (!value || typeof value !== "object") throw new Error("Managed runtime approval connection is required"); + const input = value as { protocol?: unknown; tools?: unknown; url?: unknown; token?: unknown }; + if (input.protocol !== 2 || !Array.isArray(input.tools) || !input.tools.length || input.tools.some(tool => + !tool || typeof tool.name !== "string" || typeof tool.description !== "string" || !tool.inputSchema || typeof tool.inputSchema !== "object")) { + throw new Error("Managed execution protocol 2 and tool definitions are required"); + } + if (typeof input.url !== "string" || typeof input.token !== "string" || !/^[a-f0-9]{64}$/u.test(input.token)) { + throw new Error("Invalid managed runtime approval connection"); + } + const url = new URL(input.url); + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port + || url.pathname !== "/authorize" || url.username || url.password || url.search || url.hash) { + throw new Error("Managed runtime approval connection must be local"); + } + return { url: url.href, token: input.token, tools: structuredClone(input.tools), active: true, signal }; +} + +export function managedToolDefinitions(): Record[] { + const scope = scopes.getStore(); + if (!scope?.active || scope.signal.aborted) return []; + return scope.tools.map(tool => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.inputSchema } })); +} + +export class ManagedExecutionError extends Error { + constructor(readonly detail: { kind: "permission" | "execution"; code: string }) { + super(`Managed runtime ${detail.kind === "permission" ? "did not authorize this operation" : "execution failed"}: ${detail.code}`); + } +} + +export function withManagedExecution(scope: ManagedExecutionScope | undefined, run: () => T): T { + return scope ? scopes.run(scope, run) : run(); +} + +/** Outside the authenticated request scope, managed runtimes cannot execute tools. */ +export async function executeManagedTool(name: string, args: unknown, signal?: AbortSignal | null, callId: string = randomUUID(), parentCallId?: string): Promise<{ args: unknown; result?: string }> { + const scope = scopes.getStore(); + if (!scope) { + if (managedExecutionRequired()) throw new Error("Managed runtime approval context is missing"); + return { args }; + } + if (!scope.active || scope.signal.aborted || signal?.aborted) throw new Error("Managed runtime run has ended"); + // A detached copy prevents a hook or parallel caller from changing approved parameters. + const approvedArgs = JSON.parse(JSON.stringify(args)); + if (!parentCallId) emitManagedToolEvent({ type: "tool_call", callId, name, input: approvedArgs }); + // Six Host hops may each wait 120s for approval and 15s for IO, plus transport grace. + const response = await fetch(scope.url, { + method: "POST", redirect: "error", + headers: { "content-type": "application/json", authorization: `Bearer ${scope.token}` }, + body: JSON.stringify({ callId, toolName: name, args: approvedArgs, + ...(["read_file", "list_dir", "find_files", "grep", "authorize_read_path"].includes(name) ? { fileAccessProtocol: 1 } : {}), + ...(parentCallId ? { parentCallId } : {}) }), + signal: AbortSignal.any([scope.signal, ...(signal ? [signal] : []), AbortSignal.timeout(["web_fetch", "web_search"].includes(name) ? 815_000 : 125_000)]) + }); + if (!response.ok) throw new Error("Managed runtime approval service is unavailable"); + const result = await response.json() as { decision?: string; reason?: string; result?: unknown; error?: { kind?: unknown; code?: unknown } }; + if (!scope.active || scope.signal.aborted || signal?.aborted) throw new Error("Managed runtime run has ended"); + if (result.error && ["permission", "execution"].includes(String(result.error.kind)) + && typeof result.error.code === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,79}$/u.test(result.error.code)) { + throw new ManagedExecutionError({ kind: result.error.kind as "permission" | "execution", code: result.error.code }); + } + if (result.decision !== "allow") { + const message = `Managed runtime denied this operation: ${result.reason ?? "denied"}`; + if (result.decision === "deny" && ["operator_rejected", "denied_by_policy", "policy_denied", "unconfigured_deny"].includes(result.reason ?? "")) { + throw new PathDeniedError(message); + } + throw new Error(message); + } + if (typeof result.result !== "string") throw new Error("Managed runtime execution result is missing"); + return { args: approvedArgs, ...(typeof result.result === "string" ? { result: result.result } : {}) }; +} + +export function emitManagedToolEvent(event: Record): void { + const scope = scopes.getStore(); + if (scope?.active && !scope.signal.aborted) scope.onEvent?.(event); +} diff --git a/App/memmy-agent/src/core/agent-runtime/runner.ts b/App/memmy-agent/src/core/agent-runtime/runner.ts index 82e2b39ac..b8290db11 100644 --- a/App/memmy-agent/src/core/agent-runtime/runner.ts +++ b/App/memmy-agent/src/core/agent-runtime/runner.ts @@ -1,3 +1,4 @@ +import { executeManagedTool, emitManagedToolEvent, managedExecutionRequired, ManagedExecutionError } from "./managed-execution.js"; import { requestTiming } from "../../utils/request-timing.js"; import crypto from "node:crypto"; import { redactBrowserAuthArguments } from "../../utils/browser-auth-privacy.js"; @@ -1060,6 +1061,8 @@ export class AgentRunner { } } } + for (const item of out) emitManagedToolEvent({ type: "tool_result", callId: item.call.id, name: item.call.name, + output: item.result, ok: item.event.status !== "error", ...(item.event.executionError ? { error: item.event.executionError } : {}) }); return out; } @@ -1074,6 +1077,17 @@ export class AgentRunner { const event = { name: call.name, status: "error", detail: "task cancelled" }; return { call, result: "Error: task cancelled", event, error: spec.failOnToolError ? createAbortError() : null }; } + if (managedExecutionRequired()) { + try { + const authorized = await executeManagedTool(call.name, call.arguments, spec.abortSignal, call.id); + return { call, result: authorized.result, event: { name: call.name, status: "ok" } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Managed runtime execution failed"; + return { call, result: `Error: ${message}`, event: { name: call.name, status: "error", detail: message, + ...(error instanceof ManagedExecutionError ? { executionError: error.detail } : {}) }, + error: spec.failOnToolError ? error : null }; + } + } const repeatedLookup = repeatedExternalLookupError(call.name, call.arguments, externalLookupCounts); if (repeatedLookup) { const event = { name: call.name, status: "error", detail: "repeated external lookup blocked" }; diff --git a/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts b/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts index b80a31c1f..4e1732363 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts @@ -1,3 +1,4 @@ +import { AuthorizedReadBatch, managedExecutionRequired } from "../managed-execution.js"; import fs from "node:fs/promises"; import fsSync from "node:fs"; import path from "node:path"; @@ -784,7 +785,8 @@ export class WriteFileTool extends Tool { writeStarted = true; await fs.writeFile(target, params.content, { encoding: "utf8", signal: signal ?? undefined }); this.fileStates().recordWrite(target); - if (!this.postWriteValidation) return `Successfully wrote ${target}`; + // Managed writes must not launch unapproved validators as hidden subprocesses. + if (!this.postWriteValidation || managedExecutionRequired()) return `Successfully wrote ${target}`; const content = await readBackWrittenFile(target, params.content, signal); const lint = await lintFile({ path: target, content }, { abortSignal: signal }); return appendFileLintResults(`Successfully wrote ${target}`, [lint]); @@ -798,6 +800,55 @@ export class WriteFileTool extends Tool { } } +interface EditFileParams { + path?: string; + old_text?: string; + oldText?: string; + new_text?: string; + newText?: string; + replace_all?: boolean; + replaceAll?: boolean; + occurrence?: number | null; + line_hint?: number | null; + lineHint?: number | null; + expected_replacements?: number | null; + expectedReplacements?: number | null; +} + +function resolveEditParams(params: EditFileParams) { + return { + oldText: params.old_text ?? params.oldText, + newText: params.new_text ?? params.newText, + replaceAll: params.replace_all ?? params.replaceAll ?? false, + occurrence: params.occurrence ?? null, + lineHint: params.line_hint ?? params.lineHint ?? null, + expectedReplacements: params.expected_replacements ?? params.expectedReplacements ?? null, + }; +} + +/** Pure validation shared by registry preparation and direct execution, before any file access. */ +function editOptionsError(options: ReturnType): string | null { + const { oldText, replaceAll, occurrence, lineHint, expectedReplacements } = options; + for (const [name, value] of [ + ["occurrence", occurrence], ["line_hint", lineHint], ["expected_replacements", expectedReplacements], + ] as const) { + if (value != null && value < 1) return `${name} must be >= 1.`; + if (value != null && !Number.isInteger(value)) return `${name} must be a positive integer.`; + } + if (replaceAll && occurrence != null) return "occurrence cannot be used with replace_all=true."; + if (replaceAll && lineHint != null) return "line_hint cannot be used with replace_all=true."; + if (occurrence != null && lineHint != null) return "line_hint cannot be used with occurrence."; + if (oldText === "") { + if (replaceAll || occurrence != null || lineHint != null) { + return "old_text is empty (create mode); omit occurrence and line_hint, and leave replace_all false."; + } + if (expectedReplacements != null && expectedReplacements !== 1) { + return `expected ${expectedReplacements} replacements but would make 1 (create mode).`; + } + } + return null; +} + export class EditFileTool extends Tool { static scopes = new Set(["core", "subagent", "memory"]); static MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024; @@ -838,6 +889,9 @@ export class EditFileTool extends Tool { return ( "Perform a small, exact replacement in one file by replacing old_text with new_text. " + "Use this for narrow text substitutions with old_text copied from read_file. " + + "Choose at most one selector: occurrence, line_hint, or replace_all=true; they are mutually exclusive. " + + "With no selector, old_text must match exactly once. expected_replacements is an independent count assertion. " + + "Empty old_text creates a missing file or fills an empty/whitespace-only file; omit selectors in this mode. " + "For multi-file, structural, or generated code edits, prefer apply_patch." ); } @@ -846,23 +900,30 @@ export class EditFileTool extends Tool { return { type: "object", properties: { - path: { type: "string" }, - old_text: { type: "string" }, - oldText: { type: "string" }, - new_text: { type: "string" }, - newText: { type: "string" }, - replace_all: { type: "boolean" }, - replaceAll: { type: "boolean" }, - occurrence: { type: ["integer", "null"], minimum: 1 }, - line_hint: { type: ["integer", "null"], minimum: 1 }, - lineHint: { type: ["integer", "null"], minimum: 1 }, - expected_replacements: { type: ["integer", "null"], minimum: 1 }, - expectedReplacements: { type: ["integer", "null"], minimum: 1 }, + path: { type: "string", description: "File path, absolute or relative to the workspace." }, + old_text: { type: "string", description: "Required text to replace, copied from read_file. Empty string selects create mode: create a missing file or fill an empty/whitespace-only file, without selectors." }, + oldText: { type: "string", description: "Alias of old_text; prefer old_text. The snake_case value takes precedence when both are supplied." }, + new_text: { type: "string", description: "Required replacement text; empty string deletes the selected text." }, + newText: { type: "string", description: "Alias of new_text; prefer new_text. The snake_case value takes precedence when both are supplied." }, + replace_all: { type: "boolean", description: "Replace every match when true; defaults to false. When true, cannot be combined with non-null occurrence or line_hint, or used in create mode. When false, may be combined with occurrence or line_hint, or used in create mode." }, + replaceAll: { type: "boolean", description: "Alias of replace_all, with the same restrictions; prefer replace_all. The snake_case value takes precedence when both are supplied." }, + occurrence: { type: ["integer", "null"], minimum: 1, description: "Select the 1-based occurrence of old_text in file order. Omit or use null when unused. Cannot be combined with line_hint or replace_all=true, or used in create mode." }, + line_hint: { type: ["integer", "null"], minimum: 1, description: "Select the match whose start is nearest this 1-based line number; equal-distance ties fail. Omit or use null when unused. Cannot be combined with occurrence or replace_all=true, or used in create mode." }, + lineHint: { type: ["integer", "null"], minimum: 1, description: "Alias of line_hint, with the same restrictions; prefer line_hint. A non-null line_hint takes precedence when both are supplied." }, + expected_replacements: { type: ["integer", "null"], minimum: 1, description: "Independent assertion of the number of selected replacements, checked before writing. Works with every selection mode; does not select a match. Omit or use null to skip; create mode accepts only 1 when supplied, including a no-op." }, + expectedReplacements: { type: ["integer", "null"], minimum: 1, description: "Alias of expected_replacements, with the same restrictions; prefer expected_replacements. A non-null snake_case value takes precedence when both are supplied." }, }, required: ["path"], }; } + validateParams(params: Record): string[] { + const errors = super.validateParams(params); + if (errors.length) return errors; + const error = editOptionsError(resolveEditParams(params)); + return error ? [error] : []; + } + setContext(ctx: any): void { this.requestContext = ctx; } @@ -871,32 +932,14 @@ export class EditFileTool extends Tool { return resolveFileStates(this.fileStateOwner, this.fallbackFileStates, this.requestContext); } - async execute(params: { - path?: string; - old_text?: string; - oldText?: string; - new_text?: string; - newText?: string; - replace_all?: boolean; - replaceAll?: boolean; - occurrence?: number | null; - line_hint?: number | null; - lineHint?: number | null; - expected_replacements?: number | null; - expectedReplacements?: number | null; - } = {}, context?: ToolExecutionContext): Promise { + async execute(params: EditFileParams = {}, context?: ToolExecutionContext): Promise { if (!params.path) return "Error editing file: Unknown path"; - const oldText = params.old_text ?? params.oldText; - const newText = params.new_text ?? params.newText; + const options = resolveEditParams(params); + const { oldText, newText, replaceAll, occurrence, lineHint, expectedReplacements } = options; if (oldText == null) return "Error editing file: Unknown old_text"; if (newText == null) return "Error editing file: Unknown new_text"; - const replaceAll = params.replace_all ?? params.replaceAll ?? false; - const occurrence = params.occurrence ?? null; - const lineHint = params.line_hint ?? params.lineHint ?? null; - const expectedReplacements = params.expected_replacements ?? params.expectedReplacements ?? null; - if (occurrence != null && occurrence < 1) return "Error: occurrence must be >= 1."; - if (lineHint != null && lineHint < 1) return "Error: line_hint must be >= 1."; - if (expectedReplacements != null && expectedReplacements < 1) return "Error: expected_replacements must be >= 1."; + const parameterError = editOptionsError(options); + if (parameterError) return `Error: ${parameterError}`; const target = resolveWorkspacePath(this.workspace, params.path); if (!isToolPathAllowed(target, this.allowedDir, this.extraAllowedDirs)) return `Error editing file: path outside workspace: ${params.path}`; const signal = context?.abortSignal ?? null; @@ -924,7 +967,7 @@ export class EditFileTool extends Tool { await fs.writeFile(target, newText, { encoding: "utf8", signal: signal ?? undefined }); this.fileStates().recordWrite(target); const success = exists ? "Successfully edited file" : `Successfully created ${target}`; - if (!this.postWriteValidation) return success; + if (!this.postWriteValidation || managedExecutionRequired()) return success; const content = await readBackWrittenFile(target, newText, signal); const lint = await lintFile({ path: target, content }, { abortSignal: signal }); return appendFileLintResults(success, [lint]); @@ -944,9 +987,6 @@ export class EditFileTool extends Tool { throwIfAborted(signal); if (!matches.length) return notFoundMessage(normalizedOld, normalized, params.path); const count = matches.length; - if (replaceAll && occurrence != null) return "Error: occurrence cannot be used with replace_all=true."; - if (replaceAll && lineHint != null) return "Error: line_hint cannot be used with replace_all=true."; - if (occurrence != null && lineHint != null) return "Error: line_hint cannot be used with occurrence."; if (count > 1 && !replaceAll) { if (occurrence != null) { if (occurrence > count) return `Error: occurrence ${occurrence} is out of range; old_text appears ${count} times.`; @@ -995,7 +1035,7 @@ export class EditFileTool extends Tool { await fs.writeFile(target, updated, { encoding: "utf8", signal: signal ?? undefined }); this.fileStates().recordWrite(target); const success = warning ? `${warning}\nSuccessfully edited file` : "Successfully edited file"; - if (!this.postWriteValidation) return success; + if (!this.postWriteValidation || managedExecutionRequired()) return success; const content = await readBackWrittenFile(target, updated, signal); const lint = await lintFile({ path: target, @@ -1065,22 +1105,28 @@ export class ListDirTool extends Tool { if (!isToolPathAllowed(target, this.allowedDir, this.extraAllowedDirs)) return `Error: path outside workspace: ${params.path}`; const maxEntries = params.max_entries ?? params.maxEntries ?? 200; const rows: string[] = []; + const batch = new AuthorizedReadBatch(target); const walk = async (dir: string) => { - const entries = (await fs.readdir(dir, { withFileTypes: true })) - .filter((entry) => !IGNORED_DIRS.has(entry.name)) + const authorizedEntries = await batch.read(dir, () => fs.readdir(dir, { withFileTypes: true })); + if (!authorizedEntries) return; + if (dir !== target) rows.push(workspaceRelative(this.workspace, dir).replaceAll(path.sep, "/") + "/"); + const entries = authorizedEntries.filter((entry) => !IGNORED_DIRS.has(entry.name)) .sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { const full = path.join(dir, entry.name); const rel = workspaceRelative(this.workspace, full).replaceAll(path.sep, "/") + (entry.isDirectory() ? "/" : ""); - rows.push(rel); - if (params.recursive && entry.isDirectory()) await walk(full); + if (params.recursive && entry.isDirectory()) { + await walk(full); + } else { + await batch.read(full, async () => { rows.push(rel); }); + } } }; if (!fsSync.existsSync(target)) return `Error: path not found: ${params.path}`; await walk(target); - if (!rows.length) return "(empty directory)"; + if (!rows.length) return batch.format(batch.deniedPaths ? "(no authorized entries returned)" : "(empty directory)"); const selected = rows.slice(0, maxEntries); if (rows.length > maxEntries) selected.push(`... truncated (${maxEntries} of ${rows.length} entries)`); - return selected.join("\n"); + return batch.format(selected.join("\n")); } } diff --git a/App/memmy-agent/src/core/agent-runtime/tools/loader.ts b/App/memmy-agent/src/core/agent-runtime/tools/loader.ts index 6004d7bfb..1212c4c60 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/loader.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/loader.ts @@ -1,3 +1,4 @@ +import { managedExecutionRequired } from "../managed-execution.js"; import { DesktopScreenCaptureTool } from '../../../tools/computer-use/desktop-screen-capture.js'; import fs from "node:fs"; import path from "node:path"; @@ -222,6 +223,8 @@ export class ToolLoader { } discoverPlugins(): ToolClass[] { + // Workspace code cannot run before its file/process permissions have been checked. + if (managedExecutionRequired()) return []; const out: ToolClass[] = []; const seenPackages = new Set(); for (const nodeModules of nearestNodeModulesRoots(this.workspace)) { diff --git a/App/memmy-agent/src/core/agent-runtime/tools/registry.ts b/App/memmy-agent/src/core/agent-runtime/tools/registry.ts index 76e67acca..ca9777052 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/registry.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/registry.ts @@ -1,3 +1,4 @@ +import { managedExecutionRequired, managedToolDefinitions } from "../managed-execution.js"; import { Tool, type ToolExecutionContext } from "./base.js"; const HINT = "\n\n[Analyze the error above and try a different approach.]"; @@ -29,6 +30,7 @@ export class ToolRegistry { } getDefinitions(): Record[] { + if (managedExecutionRequired()) return managedToolDefinitions(); if (this.cachedDefinitions) return this.cachedDefinitions; const definitions = [...this.tools.values()].map((tool) => tool.toSchema()); const builtins = definitions.filter((schema) => !ToolRegistry.schemaName(schema).startsWith("mcp_")); diff --git a/App/memmy-agent/src/core/agent-runtime/tools/search.ts b/App/memmy-agent/src/core/agent-runtime/tools/search.ts index 208b3d7a8..44be977e3 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/search.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/search.ts @@ -1,7 +1,9 @@ +import { AuthorizedReadBatch, managedExecutionRequired } from "../managed-execution.js"; import fs from "node:fs/promises"; import fsSync from "node:fs"; import path from "node:path"; import { minimatch } from "minimatch"; +import { extractText } from "../../../utils/document.js"; import { Tool } from "./base.js"; import { isPathInside, resolveWorkspacePath, workspaceRelative } from "./path-utils.js"; import { getMediaDir } from "../../../config/paths.js"; @@ -157,7 +159,7 @@ export class SearchTool extends Tool { return path.relative(root, target).replaceAll(path.sep, "/") || "."; } - async *iterFiles(root: string): AsyncIterable { + async *iterFiles(root: string, batch = new AuthorizedReadBatch(root)): AsyncIterable { const stat = await safeStat(root); if (!stat) return; if (stat.isFile()) { @@ -165,11 +167,15 @@ export class SearchTool extends Tool { return; } if (!stat.isDirectory()) return; - const entries = await fs.readdir(root, { withFileTypes: true }); + const entries = await batch.read(root, () => fs.readdir(root, { withFileTypes: true })); + if (!entries) return; const dirs = entries.filter((entry) => entry.isDirectory() && !IGNORE_DIRS.has(entry.name)).map((entry) => entry.name).sort(); const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort(); - for (const file of files) yield path.join(root, file); - for (const dir of dirs) yield* this.iterFiles(path.join(root, dir)); + for (const file of files) { + const target = path.join(root, file); + yield target; + } + for (const dir of dirs) yield* this.iterFiles(path.join(root, dir), batch); } } @@ -213,7 +219,7 @@ export class FindFilesTool extends SearchTool { }; } - async *iterPaths(root: string, { includeDirs = false }: { includeDirs?: boolean } = {}): AsyncIterable { + async *iterPaths(root: string, { includeDirs = false }: { includeDirs?: boolean } = {}, batch = new AuthorizedReadBatch(root)): AsyncIterable { const stat = await safeStat(root); if (!stat) return; if (stat.isFile()) { @@ -221,15 +227,18 @@ export class FindFilesTool extends SearchTool { return; } if (!stat.isDirectory()) return; + const entries = await batch.read(root, () => fs.readdir(root, { withFileTypes: true })); + if (!entries) return; if (includeDirs) yield root; - const entries = await fs.readdir(root, { withFileTypes: true }); const dirs = entries.filter((entry) => entry.isDirectory() && !IGNORE_DIRS.has(entry.name)).map((entry) => entry.name).sort(); const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort(); - for (const file of files) yield path.join(root, file); + for (const file of files) { + const target = path.join(root, file); + yield target; + } for (const dir of dirs) { const child = path.join(root, dir); - if (includeDirs) yield child; - yield* this.iterPaths(child, { includeDirs }); + yield* this.iterPaths(child, { includeDirs }, batch); } } @@ -262,7 +271,8 @@ export class FindFilesTool extends SearchTool { const root = stat.isDirectory() ? target : path.dirname(target); const matches: Array<{ display: string; mtime: number }> = []; - for await (const candidate of this.iterPaths(target, { includeDirs })) { + const batch = new AuthorizedReadBatch(target); + for await (const candidate of this.iterPaths(target, { includeDirs }, batch)) { const candidateStat = await safeStat(candidate); if (!candidateStat) continue; if (candidateStat.isDirectory() && !includeDirs) continue; @@ -273,14 +283,16 @@ export class FindFilesTool extends SearchTool { if (candidateStat.isFile() && !matchesType(name, params.type)) continue; if (candidateStat.isDirectory() && params.type) continue; if (!matchesQuery(displayPath, params.query)) continue; - matches.push({ display: displayPath + (candidateStat.isDirectory() ? "/" : ""), mtime: candidateStat.mtimeMs }); + const add = async () => { matches.push({ display: displayPath + (candidateStat.isDirectory() ? "/" : ""), mtime: candidateStat.mtimeMs }); }; + if (candidateStat.isDirectory()) await add(); + else await batch.read(candidate, add); } matches.sort((a, b) => (sort === "modified" ? b.mtime - a.mtime || a.display.localeCompare(b.display) : a.display.localeCompare(b.display))); const [paged, truncated] = paginate(matches.map((item) => item.display), limit, offset); - if (!paged.length) return "No files found"; + if (!paged.length) return batch.format(batch.deniedPaths ? "No authorized files found" : "No files found"); const note = paginationNote(limit, offset, truncated); - return note ? `${paged.join("\n")}\n\n${note}` : paged.join("\n"); + return batch.format(note ? `${paged.join("\n")}\n\n${note}` : paged.join("\n")); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EACCES") return `Error: ${(error as Error).message}`; return `Error finding files: ${(error as Error).message}`; @@ -310,7 +322,7 @@ export class GrepTool extends SearchTool { return ( "Search file contents with a regex pattern. " + "Default output_mode is files_with_matches (file paths only); use content mode for matching lines with context. " + - "Prefer this over shell grep for ordinary workspace searches. Skips binary and files >2 MB. Supports glob/type filtering." + "Prefer this over shell grep for ordinary workspace searches. In managed runs, also searches PDF and DOCX text. Skips other binary files and files >2 MB. Supports glob/type filtering." ); } @@ -412,7 +424,8 @@ export class GrepTool extends SearchTool { const counts = new Map(); const fileMtimes = new Map(); - for await (const filePath of this.iterFiles(target)) { + const batch = new AuthorizedReadBatch(target); + for await (const filePath of this.iterFiles(target, batch)) { const fileStat = await safeStat(filePath); if (!fileStat?.isFile()) continue; const relPath = path.relative(root, filePath).replaceAll(path.sep, "/") || path.basename(filePath); @@ -422,17 +435,22 @@ export class GrepTool extends SearchTool { skippedLarge += 1; continue; } - const raw = await fs.readFile(filePath).catch(() => null); - if (!raw || isBinary(raw)) { - skippedBinary += 1; - continue; - } let content: string; - try { + if (managedExecutionRequired() && [".pdf", ".docx"].includes(path.extname(filePath).toLowerCase())) { + const extracted = await batch.read(filePath, () => extractText(filePath, { maxChars: null })); + if (extracted === undefined) continue; + if (extracted == null || extracted.startsWith("[error:")) { + return `Error searching document ${this.displayPath(filePath, root)}: ${extracted ?? "unsupported format"}`; + } + content = extracted; + } else { + const raw = await batch.read(filePath, () => fs.readFile(filePath).catch(() => null)); + if (raw === undefined) continue; + if (!raw || isBinary(raw)) { + skippedBinary += 1; + continue; + } content = raw.toString("utf8"); - } catch { - skippedBinary += 1; - continue; } const lines = content.split(/\r?\n/); @@ -512,7 +530,7 @@ export class GrepTool extends SearchTool { const total = [...counts.values()].reduce((sum, count) => sum + count, 0); notes.push(`(total matches: ${total} in ${counts.size} files)`); } - return notes.length ? `${result}\n\n${notes.join("\n")}` : result; + return batch.format(notes.length ? `${result}\n\n${notes.join("\n")}` : result); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EACCES") return `Error: ${(error as Error).message}`; return `Error searching files: ${(error as Error).message}`; diff --git a/App/memmy-agent/src/entrypoints/cli/commands.ts b/App/memmy-agent/src/entrypoints/cli/commands.ts index 0d3185cce..33241a4c0 100644 --- a/App/memmy-agent/src/entrypoints/cli/commands.ts +++ b/App/memmy-agent/src/entrypoints/cli/commands.ts @@ -45,7 +45,7 @@ import { getConfigPath, } from "../../config/loader.js"; import { Config } from "../../config/schema.js"; -import { getCliHistoryPath, getDataDir, getWorkspacePath } from "../../config/paths.js"; +import { getCliHistoryPath, getDataDir, getWorkspacePath, getWorkspaceStatePath } from "../../config/paths.js"; import { CronService } from "../../cron/service.js"; import { CronJob, CronPayload } from "../../cron/types.js"; import { HeartbeatService } from "../../heartbeat/service.js"; @@ -270,8 +270,9 @@ export function modelDisplay(config: Config): [string, string] { export function syncRuntimeWorkspaceTemplates(config: Config): string { const workspacePath = getWorkspacePath(config.agents.defaults.workspace); - fs.mkdirSync(workspacePath, { recursive: true }); - syncWorkspaceTemplates(workspacePath, CLI_TEMPLATES_DIR, { + const statePath = getWorkspaceStatePath(workspacePath); + fs.mkdirSync(statePath, { recursive: true }); + syncWorkspaceTemplates(statePath, CLI_TEMPLATES_DIR, { fileMemoryEnabled: config.fileMemory.enabled, }); return workspacePath; diff --git a/App/memmy-agent/src/entrypoints/cli/linux-systemd-gateway.ts b/App/memmy-agent/src/entrypoints/cli/linux-systemd-gateway.ts index 24488c9ef..806543d63 100644 --- a/App/memmy-agent/src/entrypoints/cli/linux-systemd-gateway.ts +++ b/App/memmy-agent/src/entrypoints/cli/linux-systemd-gateway.ts @@ -152,6 +152,10 @@ function unavailableNetworkError(error: unknown): boolean { ]).has(errorCode(error) ?? ""); } +function transientGatewayProbe(result: GatewayProbe): boolean { + return result.status === "unexpected" && /^bootstrap HTTP 5\d\d$/.test(result.detail); +} + function gatewayEndpoint(config: Config): GatewayEndpoint { const options = tuiGatewayOptionsFromConfig(config, "cli:systemd-probe"); return { @@ -524,7 +528,7 @@ async function waitForGatewayReady( continue; } lastDetail = result.detail; - if (result.status === "unexpected") { + if (result.status === "unexpected" && !transientGatewayProbe(result)) { throw new LinuxSystemdGatewayError( `Gateway endpoint is occupied, incompatible, or rejected authentication (${result.detail}).`, ); @@ -576,7 +580,7 @@ export async function runLinuxRootTerminal( + "Stop that Gateway before starting the installed Memmy CLI.", ); } - if (result.status === "unexpected" && servicePid === null) { + if (result.status === "unexpected" && servicePid === null && !transientGatewayProbe(result)) { throw new LinuxSystemdGatewayError( `Gateway endpoint is occupied, incompatible, or rejected authentication (${result.detail}).`, ); @@ -595,7 +599,7 @@ export async function runLinuxRootTerminal( + "Stop that Gateway before starting the installed Memmy CLI.", ); } - if (result.status === "unexpected" && servicePid === null) { + if (result.status === "unexpected" && servicePid === null && !transientGatewayProbe(result)) { throw new LinuxSystemdGatewayError( `Gateway endpoint is occupied, incompatible, or rejected authentication (${result.detail}).`, ); @@ -609,7 +613,8 @@ export async function runLinuxRootTerminal( const environmentChanged = dependencies.prepareGatewayEnvironment ? await dependencies.prepareGatewayEnvironment() : await prepareSystemdGatewayEnvironment(); - restart ||= environmentChanged || result.status === "unexpected"; + restart ||= environmentChanged + || (result.status === "unexpected" && !transientGatewayProbe(result)); await (dependencies.enableGatewayService ?? enableSystemdGatewayService)({ restart }); await waitForGatewayReady(config, dependencies); diff --git a/App/memmy-agent/src/entrypoints/cli/startup-migrations.ts b/App/memmy-agent/src/entrypoints/cli/startup-migrations.ts index c1be84c0d..1e1b0472c 100644 --- a/App/memmy-agent/src/entrypoints/cli/startup-migrations.ts +++ b/App/memmy-agent/src/entrypoints/cli/startup-migrations.ts @@ -56,9 +56,11 @@ export function resolveStartupMigrationTarget( env, ); if (input.config) setConfigPath(runtimeConfigFile); + const managed = env.AGENT_MANAGED_EXECUTION_REQUIRED === "1"; + if (managed && !path.isAbsolute(env.MEMMY_AGENT_DATA_DIR ?? "")) throw new Error("Managed runtime data directory is required"); return resolveMigrationTargets({ runtimeConfigFile, - agentWorkspaceOverride: input.workspace ?? env.MEMMY_AGENT_WORKSPACE, + agentWorkspaceOverride: managed ? path.join(env.MEMMY_AGENT_DATA_DIR!, "workspace") : input.workspace ?? env.MEMMY_AGENT_WORKSPACE, sessionDagDirOverride: env.MEMMY_AGENT_SESSION_DAG_DIR, appDatabaseFile: input.appDatabase ?? env[APP_DATABASE_ENV], env, diff --git a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts index d3af8afb7..6bd851169 100644 --- a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts +++ b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts @@ -1,3 +1,4 @@ +import { authenticateAgentRuntime, createManagedExecutionScope, managedExecutionRequired, withManagedExecution } from "../../core/agent-runtime/managed-execution.js"; import { requestTiming, withRequestTiming } from "../../utils/request-timing.js"; import fs from "node:fs"; import path from "node:path"; @@ -252,6 +253,12 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P sessionLocks: new Map>(), activeRequests: new Map(), }; + const managed = managedExecutionRequired(); + if (managed && !authenticateAgentRuntime(request)) return errorJson(401, "Managed runtime authentication required"); + const controller = new AbortController(); + const permissionAbort = new AbortController(); + const permissionSignal = AbortSignal.any([request.signal, permissionAbort.signal, controller.signal]); + let permission: ReturnType | undefined; const contentType = request.headers.get("content-type") ?? ""; let text = ""; let mediaPaths: string[] = []; @@ -262,6 +269,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P try { if (contentType.startsWith("multipart/")) { + if (managed) return errorJson(400, "Managed requests require a JSON approval context"); [text, mediaPaths, sessionId, requestedModel] = await parseMultipart(request); } else { let body: any; @@ -270,6 +278,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P } catch { return errorJson(400, "Invalid JSON body"); } + if (managed) permission = createManagedExecutionScope(body.managed_execution, permissionSignal); stream = Boolean(body.stream); requestedModel = body.model; [text, mediaPaths] = parseJsonContent(body); @@ -293,7 +302,6 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P const sessionKey = sessionId ? `api:${sessionId}` : API_SESSION_KEY; const requestKey = JSON.stringify([sessionKey, requestId]); if (context.activeRequests.has(requestKey)) return errorJson(409, "Request is already running"); - const controller = new AbortController(); let finish!: () => void; const done = new Promise((resolve) => { finish = resolve; }); context.activeRequests.set(requestKey, { controller, done }); @@ -315,6 +323,9 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P ? { metadata: { model_preset: context.agentLoop.modelPreset } } : {}), }; + const invoke = (args: Record) => withManagedExecution(permission, () => callAgent(context, args)); + const endPermission = () => { if (permission) permission.active = false; permissionAbort.abort(); }; + if (stream) { const chunkId = `chatcmpl-${crypto.randomUUID().slice(0, 12)}`; const encoder = new TextEncoder(); @@ -330,6 +341,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P closed = true; } }; + if (permission) permission.onEvent = event => write(`data: ${JSON.stringify({ memmy_event: event })}\n\n`); const close = (): void => { if (closed) return; closed = true; @@ -355,13 +367,13 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P for (const event of options.toolEvents ?? []) { const phase = String(event?.phase ?? ""); if (phase === "start") { - write(sseMemmyEvent("tool_call", { + if (!permission) write(sseMemmyEvent("tool_call", { callId: String(event.call_id ?? event.ui_tool_call_id ?? "call"), name: String(event.name ?? "tool"), input: event.arguments ?? {}, }, context.modelName, chunkId)); } else if (phase === "end" || phase === "error") { - write(sseMemmyEvent("tool_result", { + if (!permission) write(sseMemmyEvent("tool_result", { callId: String(event.call_id ?? event.ui_tool_call_id ?? "call"), name: String(event.name ?? "tool"), output: phase === "error" ? event.error ?? "Tool execution failed" : event.result ?? null, @@ -379,7 +391,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P controller.signal.throwIfAborted(); requestTiming("api.session_lock_acquired"); // Model and tool calls own their deadlines; a whole turn has none. - const response = await callAgent(context, { + const response = await invoke({ ...baseArgs, onProgress, onStream, @@ -400,6 +412,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P message: controller.signal.aborted ? "Task cancelled by user." : selectionUnavailable ? "会话绑定的模型已不可用,请重新选择模型。" : "Memmy runtime failed while processing the request.", } })}\n\n`); } finally { + endPermission(); if (!failed) { write(sseChunk("", context.modelName, chunkId, "stop")); write(SSE_DONE); @@ -412,6 +425,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P }, cancel() { closed = true; + endPermission(); }, }); return new Response(body, { @@ -427,12 +441,12 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P try { const reply = await withSessionLock(context, sessionKey, async () => { controller.signal.throwIfAborted(); - const first = await callAgent(context, baseArgs); + const first = await invoke(baseArgs); controller.signal.throwIfAborted(); let textResponse = responseText(first); let usage = responseUsage(first); if (!textResponse.trim()) { - const retry = await callAgent(context, baseArgs); + const retry = await invoke(baseArgs); controller.signal.throwIfAborted(); textResponse = responseText(retry); usage = mergeUsageRecords(usage, responseUsage(retry)); @@ -445,11 +459,13 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P if (controller.signal.aborted) return errorJson(409, "Task cancelled by user.", "cancelled"); return errorJson(500, "Internal server error", "server_error"); } finally { + endPermission(); finishRequest(); } } async function handleCancel(request: Request, ctx: ApiContext): Promise { + if (managedExecutionRequired() && !authenticateAgentRuntime(request)) return errorJson(401, "Managed runtime authentication required"); let body: any; try { body = await request.json(); @@ -513,7 +529,7 @@ export async function handleModels(request?: Request, ctx?: Pick { - return Response.json({ status: "ok" }); + return Response.json({ status: "ok", ...(managedExecutionRequired() ? { permissionProtocol: 1, managedExecutionProtocol: 2, managedExecutionRequired: true, macosSandbox: process.env.AGENT_MACOS_SANDBOX === "1" } : {}) }); } export function createApp( diff --git a/App/memmy-agent/src/templates/agent/tool-contract.md b/App/memmy-agent/src/templates/agent/tool-contract.md index 7246ee5a1..4091d56c6 100644 --- a/App/memmy-agent/src/templates/agent/tool-contract.md +++ b/App/memmy-agent/src/templates/agent/tool-contract.md @@ -39,7 +39,9 @@ records general tool contracts and less obvious usage patterns. - For code or configuration changes, the default loop is: locate (`find_files`/`grep`), inspect (`read_file`), edit (`apply_patch`), then verify (`exec` or reread). - Use `apply_patch` as the default code editing tool, especially for multi-file changes, structural edits, generated code, moves, additions, or deletions. -- Use `edit_file` only for small exact replacements in a single file, with `old_text` copied from `read_file`; when ambiguity matters, add `occurrence`, `line_hint`, or `expected_replacements`. +- Use `edit_file` only for small exact replacements in a single file, with `old_text` copied from `read_file`. With no selector, the text must match once. To handle multiple matches, choose only one of `occurrence` (1-based match index), `line_hint` (nearest 1-based start line; ties fail), or `replace_all=true` (all matches). These three selectors are mutually exclusive; omit unused selectors or use `null` for `occurrence`/`line_hint`. +- `expected_replacements` is an independent count assertion, compatible with every selection mode; it does not select a match. For example, use `occurrence=2, expected_replacements=1` for the second match, or `replace_all=true, expected_replacements=3` for all three matches. +- If `edit_file` uses `old_text=""` to create a missing file or fill an empty/whitespace-only file, omit selectors and leave `replace_all` false. In this mode, `expected_replacements` may be omitted/null or set to `1`, including a no-op. - Use `write_file` for new files or intentional full-file rewrites, not routine local edits. - If a write tool returns `No changes made`, do not repeat the same tool call with identical arguments. Re-read the target and change the arguments or editing strategy. - If `apply_patch` or `edit_file` fails, reread with `force=true`, narrow the context, and try a smaller patch instead of switching to shell `sed` or `echo`. diff --git a/App/memmy-agent/src/version.ts b/App/memmy-agent/src/version.ts index 567189100..507b6028a 100644 --- a/App/memmy-agent/src/version.ts +++ b/App/memmy-agent/src/version.ts @@ -18,7 +18,9 @@ function readProjectVersion(): string { }; if (typeof manifest.version === "string") { packagedVersion ??= manifest.version; - if (manifest.name === "memmy-agent" && Array.isArray(manifest.workspaces)) { + // A packaged runtime must not inspect manifests outside its execution boundary. + if (manifest.name === "@memtensor/memmy-agent" + || manifest.name === "memmy-agent" && Array.isArray(manifest.workspaces)) { return manifest.version; } } diff --git a/App/memmy-agent/tests/core/agent-runtime/managed-execution.test.ts b/App/memmy-agent/tests/core/agent-runtime/managed-execution.test.ts new file mode 100644 index 000000000..b12337601 --- /dev/null +++ b/App/memmy-agent/tests/core/agent-runtime/managed-execution.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { executeManagedTool, createManagedExecutionScope, withManagedExecution } from "../../../src/core/agent-runtime/managed-execution.js"; +import { AgentRunner, AgentRunSpec } from "../../../src/core/agent-runtime/runner.js"; +import { ToolCallRequest } from "../../../src/providers/base.js"; +import { createApp } from "../../../src/entrypoints/openai-like-api/server.js"; + +const credential = { protocol: 2, tools: [{ name: "web_fetch", description: "managed fetch", inputSchema: { type: "object" } }], url: "http://127.0.0.1:12345/authorize", token: "a".repeat(64) }; +const scope = () => createManagedExecutionScope(credential, new AbortController().signal); +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); + +describe("mandatory managed execution", () => { + it("advertises the generic managed execution contract", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + vi.stubEnv("AGENT_MACOS_SANDBOX", "1"); + const response = await createApp().fetch(new Request("http://127.0.0.1/health")); + expect(await response.json()).toEqual({ status: "ok", permissionProtocol: 1, + managedExecutionProtocol: 2, managedExecutionRequired: true, macosSandbox: true }); + }); + + it("keeps standalone health independent of managed execution", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "0"); + const response = await createApp().fetch(new Request("http://127.0.0.1/health")); + expect(await response.json()).toEqual({ status: "ok" }); + }); + + it("uses the Host web result without executing any native network tool", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ decision: "allow", result: "approved page" }))); + const execute = vi.fn(); + const results = await withManagedExecution(scope(), () => new AgentRunner().executeTools(new AgentRunSpec({ + tools: { execute, get: () => ({ readOnly: true }) } as any + }), [new ToolCallRequest({ id: "web", name: "web_fetch", arguments: { url: "https://example.com" } })])); + expect(execute).not.toHaveBeenCalled(); expect(results[0].result).toContain("approved page"); + }); + it.each([false, true])("waits across redirect approvals but keeps a bounded lifetime: timeout=%s", async expires => { + vi.useFakeTimers(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockImplementation(ms => { + const controller = new AbortController(); setTimeout(() => controller.abort(new Error("timeout")), ms); return controller.signal; + }); + let reply!: (response: Response) => void; + vi.stubGlobal("fetch", vi.fn((_url, init) => new Promise((resolve, reject) => { + reply = resolve; + init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true }); + }))); + try { + const pending = withManagedExecution(scope(), () => executeManagedTool("web_fetch", { url: "https://example.com" })); + if (expires) { + const rejected = expect(pending).rejects.toThrow("timeout"); + await vi.advanceTimersByTimeAsync(900_000); await rejected; + } else { + await vi.advanceTimersByTimeAsync(150_000); + reply(Response.json({ decision: "allow", result: "after several approvals" })); + expect((await pending).result).toBe("after several approvals"); + } + } finally { timeout.mockRestore(); vi.useRealTimers(); } + }); + + it("does not fall back to direct networking when the Host omits its web result", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ decision: "allow" }))); + await expect(withManagedExecution(scope(), () => executeManagedTool("web_fetch", { url: "https://example.com" }))).rejects.toThrow("result is missing"); + }); + it("blocks managed tools without an authenticated request context", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + await expect(executeManagedTool("write_file", {})).rejects.toThrow("context is missing"); + }); + it("rejects non-local approval endpoints", () => { + for (const url of ["https://evil.example/authorize", "http://localhost:123/authorize", "http://127.0.0.1:123/authorize?redirect=x"]) { + expect(() => createManagedExecutionScope({ ...credential, url }, new AbortController().signal)).toThrow(); + } + }); + it.each([true, false])("waits for the Host and never calls the %s native-execute branch", async direct => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + let reply!: (value: Response) => void; + const received: any[] = []; + vi.stubGlobal("fetch", vi.fn((_url, init) => { received.push(JSON.parse(init.body)); return new Promise(resolve => { reply = resolve; }); })); + const execute = vi.fn(async (args: any) => args.path); + const original = { path: "before.txt" }; + const tools = { get: () => ({ readOnly: false }), prepareCall: () => [direct ? { execute } : null, original, null], execute: (_name: string, args: any) => execute(args) }; + const pending = withManagedExecution(scope(), () => new AgentRunner().executeTools(new AgentRunSpec({ tools: tools as any }), [new ToolCallRequest({ id: "one", name: "write_file", arguments: {} })])); + await vi.waitFor(() => expect(received).toHaveLength(1)); + expect(execute).not.toHaveBeenCalled(); original.path = "after.txt"; + reply(Response.json({ decision: "allow", result: "Host result" })); + await pending; + expect(execute).not.toHaveBeenCalled(); + expect(received[0].args).toEqual({}); + }); + it.each(["deny", "allow_always", "unavailable"])("never executes on %s", async decision => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + vi.stubGlobal("fetch", vi.fn(async () => decision === "unavailable" ? Promise.reject(new Error("offline")) : Response.json({ decision }))); + const execute = vi.fn(); + const results = await withManagedExecution(scope(), () => new AgentRunner().executeTools(new AgentRunSpec({ + tools: { execute, get: () => ({ readOnly: true }) } as any + }), [new ToolCallRequest({ id: "one", name: "read_file", arguments: { path: "a" } })])); + expect(execute).not.toHaveBeenCalled(); expect(results[0].event.status).toBe("error"); + }); + it("checks runtime authentication before parsing attachments or invoking the agent", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); vi.stubEnv("AGENT_RUNTIME_TOKEN", "b".repeat(64)); + const loop = vi.fn(); const app = createApp(loop); + const response = await app.fetch(new Request("http://127.0.0.1/v1/chat/completions", { method: "POST", body: "invalid json" })); + expect(response.status).toBe(401); expect(loop).not.toHaveBeenCalled(); + }); + it("carries the verified request scope to tool execution and ends it with the response", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); vi.stubEnv("AGENT_RUNTIME_TOKEN", "b".repeat(64)); + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ decision: "allow", result: "Host result" }))); + let detached!: () => Promise; + const app = createApp(async () => { + // AsyncResource preserves the request scope for deferred work, which must expire. + const { AsyncResource } = await import("node:async_hooks"); + detached = AsyncResource.bind(() => executeManagedTool("read_file", { path: "a" })); + await executeManagedTool("read_file", { path: "a" }); return "ok"; + }); + const result = await app.fetch(new Request("http://127.0.0.1/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json", "x-agent-runtime-token": "b".repeat(64) }, + body: JSON.stringify({ messages: [{ role: "user", content: "test" }], managed_execution: credential }) + })); + expect(result.status).toBe(200); await expect(detached()).rejects.toThrow("run has ended"); + }); + it.each(["allow", "deny"])("streams tool outcomes with the approval call id for %s", async decision => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); vi.stubEnv("AGENT_RUNTIME_TOKEN", "b".repeat(64)); + const requests: any[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { requests.push(JSON.parse(init.body)); return Response.json({ decision, ...(decision === "allow" ? { result: "written" } : {}) }); })); + const execute = vi.fn(async () => "written"); + const app = createApp(async (args: any) => { + await args.onProgress("", { toolEvents: [{ phase: "start", call_id: "correlated", name: "write_file", arguments: { path: "a" } }] }); + await new AgentRunner().executeTools(new AgentRunSpec({ tools: { execute, get: () => ({ readOnly: false }) } as any }), + [new ToolCallRequest({ id: "correlated", name: "write_file", arguments: { path: "a" } })]); + await args.onProgress("", { toolEvents: [{ phase: decision === "allow" ? "end" : "error", call_id: "correlated", name: "write_file", result: "written", error: "denied" }] }); + return "finished"; + }); + const response = await app.fetch(new Request("http://127.0.0.1/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json", "x-agent-runtime-token": "b".repeat(64) }, + body: JSON.stringify({ stream: true, messages: [{ role: "user", content: "test" }], managed_execution: credential }) + })); + const events = (await response.text()).split("\n\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6)).memmy_event).filter(Boolean); + expect(requests[0].callId).toBe("correlated"); + expect(events.map(event => [event.type, event.callId])).toEqual([["tool_call", "correlated"], ["tool_result", "correlated"]]); + expect(events[1].ok).toBe(decision === "allow"); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([true, false])("keeps approval pending beyond the legacy deadline and authenticates explicit stop (stream=%s)", async stream => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + vi.stubEnv("AGENT_RUNTIME_TOKEN", "b".repeat(64)); + let approvalSignal!: AbortSignal; + vi.stubGlobal("fetch", vi.fn((_url, init) => new Promise((_resolve, reject) => { + approvalSignal = init.signal; + approvalSignal.addEventListener("abort", () => reject(approvalSignal.reason), { once: true }); + }))); + const execute = vi.fn(); + const app = createApp(async () => { + await executeManagedTool("write_file", { path: "a" }); + await execute(); + return "written"; + }, "memmy", 0.001); + const responsePromise = app.fetch(new Request("http://127.0.0.1/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json", "x-agent-runtime-token": "b".repeat(64) }, + body: JSON.stringify({ stream, session_id: "s", request_id: "r", messages: [{ role: "user", content: "test" }], managed_execution: credential }) + })); + await vi.waitFor(() => expect(approvalSignal).toBeDefined()); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(app.context.activeRequests.size).toBe(1); + expect(approvalSignal.aborted).toBe(false); + const cancel = (token?: string) => app.fetch(new Request("http://127.0.0.1/v1/chat/cancel", { + method: "POST", headers: { "content-type": "application/json", ...(token ? { "x-agent-runtime-token": token } : {}) }, + body: JSON.stringify({ session_id: "s", request_id: "r" }) + })); + expect((await cancel()).status).toBe(401); + expect(approvalSignal.aborted).toBe(false); + expect((await cancel("b".repeat(64))).status).toBe(200); + const response = await responsePromise; + if (stream) { + const text = await response.text(); + expect(text).toContain('"code":"cancelled"'); + expect(text).not.toContain("[DONE]"); + } else { + expect(response.status).toBe(409); + } + expect(approvalSignal.aborted).toBe(true); + expect(execute).not.toHaveBeenCalled(); + expect(app.context.activeRequests.size).toBe(0); + }); + +}); + + +describe("managed execution outcomes", () => { + it.each(["read_file", "write_file", "list_dir", "run_command", "web_fetch", "web_search"])("delegates %s entirely to the Host", async name => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ decision: "allow", result: "Error is valid file content" }))); + const native = vi.fn(() => { throw new Error("native execution must not run"); }); + const runner = new AgentRunner(); + const results = await withManagedExecution(scope(), () => runner.executeTools(new AgentRunSpec({ + tools: { prepareCall: native, execute: native, get: () => ({ readOnly: true }) } as any + }), [new ToolCallRequest({ id: name, name, arguments: { path: "file" } })])); + expect(native).not.toHaveBeenCalled(); + expect(results[0].event.status).toBe("ok"); + expect(results[0].result).toBe("Error is valid file content"); + }); + it("retains execution errors instead of relabelling them as denied approvals", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + const error = { kind: "execution", code: "ENOTFOUND" }; + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ decision: "deny", reason: "ENOTFOUND", error }))); + const results = await withManagedExecution(scope(), () => new AgentRunner().executeTools(new AgentRunSpec({ + tools: { get: () => ({ readOnly: true }) } as any + }), [new ToolCallRequest({ id: "dns", name: "web_fetch", arguments: {} })])); + expect(results[0].event).toMatchObject({ status: "error", executionError: error }); + expect(results[0].result).toContain("execution failed: ENOTFOUND"); + expect(results[0].result).not.toContain("denied"); + }); + it("requires protocol 2 and a Host tool catalog", () => { + expect(() => createManagedExecutionScope({ ...credential, protocol: 1 }, new AbortController().signal)).toThrow("protocol 2"); + expect(() => createManagedExecutionScope({ ...credential, tools: [] }, new AbortController().signal)).toThrow("tool definitions"); + }); + it("only exposes the request-scoped Host tools", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + const { ToolRegistry } = await import("../../../src/core/agent-runtime/tools/registry.js"); + const registry = new ToolRegistry(); + expect(registry.getDefinitions()).toEqual([]); + withManagedExecution(scope(), () => expect(registry.getDefinitions().map(tool => tool.function.name)).toEqual(["web_fetch"])); + expect(registry.getDefinitions()).toEqual([]); + }); +}); diff --git a/App/memmy-agent/tests/core/agent-runtime/managed-file-access.test.ts b/App/memmy-agent/tests/core/agent-runtime/managed-file-access.test.ts new file mode 100644 index 000000000..7226fe0ee --- /dev/null +++ b/App/memmy-agent/tests/core/agent-runtime/managed-file-access.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { createManagedExecutionScope, withManagedExecution } from "../../../src/core/agent-runtime/managed-execution.js"; +import { AgentRunner, AgentRunSpec } from "../../../src/core/agent-runtime/runner.js"; +import { ToolCallRequest } from "../../../src/providers/base.js"; +import { ListDirTool, ReadFileTool, WriteFileTool } from "../../../src/core/agent-runtime/tools/filesystem.js"; + +const roots: string[] = []; +afterEach(async () => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +it.each(["read_file", "write_file", "list_dir"])("%s never accesses native files after a Host grant", async name => { + const workspace = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "managed-files-"))); + roots.push(workspace); + const target = path.join(workspace, "file.txt"); + await fs.writeFile(target, "unchanged"); + const tool = name === "read_file" ? new ReadFileTool({ workspace }) + : name === "list_dir" ? new ListDirTool({ workspace }) : new WriteFileTool({ workspace }); + const native = vi.spyOn(tool, "execute"); + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + let reply!: (value: Response) => void; + const requests: any[] = []; + vi.stubGlobal("fetch", vi.fn((_url, init) => { + requests.push(JSON.parse(init.body)); return new Promise(resolve => { reply = resolve; }); + })); + const scope = createManagedExecutionScope({ protocol: 2, + tools: [{ name, description: "Host file operation", inputSchema: { type: "object" } }], + url: "http://127.0.0.1:12345/authorize", token: "a".repeat(64) }, new AbortController().signal); + const pending = withManagedExecution(scope, () => new AgentRunner().executeTools(new AgentRunSpec({ + workspace, tools: { get: () => tool, prepareCall: () => { throw new Error("native preparation must not run"); } } as any + }), [new ToolCallRequest({ id: "file-call", name, arguments: { path: target, content: "changed" } })])); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + expect(native).not.toHaveBeenCalled(); + expect(await fs.readFile(target, "utf8")).toBe("unchanged"); + reply(Response.json({ decision: "allow", result: "Host performed IO" })); + const [result] = await pending; + expect(result.result).toBe("Host performed IO"); + expect(result.event.status).toBe("ok"); + expect(native).not.toHaveBeenCalled(); + expect(await fs.readFile(target, "utf8")).toBe("unchanged"); + expect(requests[0]).toMatchObject({ callId: "file-call", toolName: name, args: { path: target } }); +}); + +it("cannot fall back to native write when the Host approves without an execution result", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ decision: "allow" }))); + const execute = vi.fn(); + const scope = createManagedExecutionScope({ protocol: 2, + tools: [{ name: "write_file", description: "write", inputSchema: {} }], + url: "http://127.0.0.1:12345/authorize", token: "a".repeat(64) }, new AbortController().signal); + const [result] = await withManagedExecution(scope, () => new AgentRunner().executeTools(new AgentRunSpec({ + tools: { execute, get: () => ({ readOnly: false }) } as any + }), [new ToolCallRequest({ id: "missing", name: "write_file", arguments: { path: "file", content: "wrong" } })])); + expect(result.event.status).toBe("error"); + expect(result.result).toContain("execution result is missing"); + expect(execute).not.toHaveBeenCalled(); +}); diff --git a/App/memmy-agent/tests/core/agent-runtime/managed-state.test.ts b/App/memmy-agent/tests/core/agent-runtime/managed-state.test.ts new file mode 100644 index 000000000..f2692b8b1 --- /dev/null +++ b/App/memmy-agent/tests/core/agent-runtime/managed-state.test.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { Config } from "../../../src/config/schema.js"; +import { getWorkspacePath, getWorkspaceStatePath } from "../../../src/config/paths.js"; +import { ContextBuilder } from "../../../src/core/agent-runtime/context.js"; +import { AgentLoop } from "../../../src/core/agent-runtime/loop.js"; +import { ToolLoader } from "../../../src/core/agent-runtime/tools/loader.js"; +import { syncRuntimeWorkspaceTemplates } from "../../../src/entrypoints/cli/commands.js"; +import { resolveStartupMigrationTarget } from "../../../src/entrypoints/cli/startup-migrations.js"; + +const roots: string[] = []; +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "managed-state-"))); roots.push(root); + const home = path.join(root, "runtime"); fs.mkdirSync(home); + const workspace = path.join(root, "unapproved-project"); + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); vi.stubEnv("MEMMY_AGENT_DATA_DIR", home); + return { root, home, workspace }; +} +it("managed templates, session and memory state never create the unapproved project directory", () => { + const { home, workspace } = fixture(); + const config = new Config({ agents: { defaults: { workspace } }, memmyMemory: { enabled: false } }); + expect(getWorkspacePath(workspace)).toBe(workspace); + expect(syncRuntimeWorkspaceTemplates(config)).toBe(workspace); + expect(fs.existsSync(workspace)).toBe(false); + fs.mkdirSync(workspace); + const loop = new AgentLoop({ config, workspace, provider: { getDefaultModel: () => "test-model" } }); + expect(loop.workspace).toBe(workspace); + expect(loop.sessions.root).toBe(path.join(home, "workspace", "sessions")); + expect(loop.context.memory.workspace).toBe(path.join(home, "workspace")); + expect(loop.tools.get("read_file")).toHaveProperty("workspace", workspace); + expect(fs.readdirSync(workspace)).toEqual([]); +}); +it("managed bootstrap context and plugin discovery cannot read project-controlled files", () => { + const { workspace, home } = fixture(); fs.mkdirSync(workspace); + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "UNAPPROVED_PROJECT_SECRET"); + const context = new ContextBuilder({ workspace }); + expect(context.loadBootstrapFiles()).not.toContain("UNAPPROVED_PROJECT_SECRET"); + expect(context.skills.workspace).toBe(path.join(home, "workspace")); + const read = vi.spyOn(fs, "readdirSync"); + expect(new ToolLoader({ workspace }).discoverPlugins()).toEqual([]); + expect(read).not.toHaveBeenCalled(); +}); +it("managed migrations target private state and require an explicit Host data directory", () => { + const { workspace, home } = fixture(); + const config = path.join(home, "config.json"); + fs.writeFileSync(config, JSON.stringify({ agents: { defaults: { workspace } } })); + const env = { AGENT_MANAGED_EXECUTION_REQUIRED: "1", MEMMY_AGENT_DATA_DIR: home, HOME: home }; + const target = resolveStartupMigrationTarget({ config, workspace }, env); + expect(target.agentWorkspace).toBe(path.join(home, "workspace")); + expect(fs.existsSync(workspace)).toBe(false); + expect(() => resolveStartupMigrationTarget({ config, workspace }, { ...env, MEMMY_AGENT_DATA_DIR: "" })).toThrow("data directory is required"); + vi.stubEnv("MEMMY_AGENT_DATA_DIR", ""); + expect(() => getWorkspaceStatePath(workspace)).toThrow("data directory is required"); +}); diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/edit-file-validation.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/edit-file-validation.test.ts new file mode 100644 index 000000000..8c30f46d5 --- /dev/null +++ b/App/memmy-agent/tests/core/agent-runtime/tools/edit-file-validation.test.ts @@ -0,0 +1,204 @@ +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditFileTool } from "../../../../src/core/agent-runtime/tools/filesystem.js"; +import { ToolRegistry } from "../../../../src/core/agent-runtime/tools/registry.js"; + +const roots: string[] = []; +type Params = Parameters[0]; + +function setup() { + const root = fsSync.mkdtempSync(path.join(os.tmpdir(), "memmy-edit-validation-")); + roots.push(root); + const tool = new EditFileTool({ workspace: root, postWriteValidation: false }); + const registry = new ToolRegistry(); + registry.register(tool); + return { root, tool, registry, file: path.join(root, "nested", "sample.txt") }; +} + +function watchFileAccess(tool: EditFileTool) { + return [ + vi.spyOn(fs, "readFile"), vi.spyOn(fs, "writeFile"), vi.spyOn(fs, "mkdir"), + vi.spyOn(fsSync, "statSync"), vi.spyOn(fsSync, "existsSync"), + vi.spyOn(tool.fileStates(), "checkRead"), vi.spyOn(tool.fileStates(), "recordWrite"), + ]; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) fsSync.rmSync(root, { recursive: true, force: true }); +}); + +describe("edit_file parameter validation", () => { + const conflicts: Params[] = [ + { occurrence: 1, line_hint: 1 }, + { replace_all: true, occurrence: 1 }, + { replace_all: true, line_hint: 1 }, + { occurrence: 1, lineHint: 1 }, + { replaceAll: true, occurrence: 1 }, + { replaceAll: true, lineHint: 1 }, + { replace_all: true, lineHint: 1 }, + { replace_all: true, occurrence: 1, line_hint: 1 }, + ]; + const scenarios = [ + { name: "matching", before: "same\nsame\n", oldText: "same", newText: "changed" }, + { name: "no match", before: "same\n", oldText: "absent", newText: "changed" }, + { name: "missing file", before: null, oldText: "same", newText: "changed" }, + { name: "create missing file", before: null, oldText: "", newText: "changed" }, + { name: "fill empty file", before: "", oldText: "", newText: "changed" }, + { name: "fill whitespace file", before: " \n", oldText: "", newText: "changed" }, + { name: "empty no-op", before: "", oldText: "", newText: "" }, + { name: "replacement no-op", before: "same\n", oldText: "same", newText: "same" }, + { name: "create over nonempty file", before: "same\n", oldText: "", newText: "changed" }, + ]; + + for (const conflict of conflicts) { + for (const scenario of scenarios) { + it(`rejects ${JSON.stringify(conflict)} before file access: ${scenario.name}`, async () => { + const { file, tool, registry } = setup(); + if (scenario.before !== null) { + await fs.mkdir(path.dirname(file)); + await fs.writeFile(file, scenario.before); + } + const params = { path: file, old_text: scenario.oldText, new_text: scenario.newText, ...conflict }; + const spies = watchFileAccess(tool); + const reportFileMutation = vi.fn(); + expect(registry.prepareCall("edit_file", params)[2]).toContain("cannot be used"); + expect(await registry.execute("edit_file", params, { reportFileMutation })).toContain("cannot be used"); + expect(await tool.execute(params, { reportFileMutation })).toContain("cannot be used"); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + expect(reportFileMutation).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + if (scenario.before === null) { + expect(fsSync.existsSync(path.dirname(file))).toBe(false); + } else { + expect(await fs.readFile(file, "utf8")).toBe(scenario.before); + } + }); + } + } + + it.each(["occurrence", "line_hint", "lineHint", "expected_replacements", "expectedReplacements"])( + "rejects invalid %s before accessing files through either entry point", async (name) => { + const { file, tool, registry } = setup(); + const spies = watchFileAccess(tool); + for (const value of [0, -1, 1.5, NaN, Infinity]) { + const params = { path: file, old_text: "same", new_text: "changed", [name]: value }; + expect(registry.prepareCall("edit_file", params)[2]).not.toBeNull(); + expect(await tool.execute(params)).toMatch(/^Error:/); + } + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { occurrence: 1 }, { line_hint: 1 }, { lineHint: 1 }, { replace_all: true }, { replaceAll: true }, + { expected_replacements: 2 }, { expectedReplacements: 99 }, + ])("rejects replacement-only options in create mode: %j", async (options) => { + const { file, tool, registry } = setup(); + const spies = watchFileAccess(tool); + const params = { path: file, oldText: "", newText: "created", ...options }; + expect(registry.prepareCall("edit_file", params)[2]).toContain("create mode"); + expect(await tool.execute(params)).toContain("create mode"); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + }); + + it("preserves normal schema validation for malformed parameter objects", () => { + const { registry } = setup(); + for (const params of [null, [], 42, { path: "sample.txt", occurrence: "invalid" }]) { + expect(registry.prepareCall("edit_file", params as any)[2]).toContain("Invalid parameters"); + } + }); +}); + +describe("edit_file valid modes and independent replacement counts", () => { + const modes = [ + { name: "unique", options: {}, before: "same\n", after: "changed\n", count: 1 }, + { name: "occurrence", options: { occurrence: 2 }, before: "same\nx\nsame\n", after: "same\nx\nchanged\n", count: 1 }, + { name: "line_hint", options: { line_hint: 4 }, before: "same\nx\nsame\n", after: "same\nx\nchanged\n", count: 1 }, + { name: "replace_all", options: { replace_all: true }, before: "same\nx\nsame\n", after: "changed\nx\nchanged\n", count: 2 }, + ]; + for (const mode of modes) { + for (const aliases of [false, true]) { + it(`${mode.name}, aliases=${aliases}: asserts count without selecting matches`, async () => { + const { file, registry } = setup(); + await fs.mkdir(path.dirname(file)); + await fs.writeFile(file, mode.before); + const params: Record = { path: file, old_text: "same", new_text: "changed", ...mode.options }; + const countKey = aliases ? "expectedReplacements" : "expected_replacements"; + if (aliases) { + for (const [snake, camel] of [["old_text", "oldText"], ["new_text", "newText"], ["line_hint", "lineHint"], ["replace_all", "replaceAll"]]) { + if (snake in params) { params[camel] = params[snake]; delete params[snake]; } + } + } + const rejected = await registry.execute("edit_file", { ...params, [countKey]: mode.count + 1 }); + expect(rejected).toContain(`expected ${mode.count + 1} replacements but would make ${mode.count}`); + expect(await fs.readFile(file, "utf8")).toBe(mode.before); + expect(await registry.execute("edit_file", { ...params, [countKey]: mode.count })).toContain("Successfully edited"); + expect(await fs.readFile(file, "utf8")).toBe(mode.after); + }); + } + } + + it.each([undefined, null, 1])("accepts create mode with expected_replacements=%s", async (count) => { + for (const before of [null, "", " \n"]) { + const { file, registry } = setup(); + if (before !== null) { await fs.mkdir(path.dirname(file)); await fs.writeFile(file, before); } + const result = await registry.execute("edit_file", { + path: file, old_text: "", new_text: "created", replace_all: false, + occurrence: null, line_hint: null, ...(count === undefined ? {} : { expected_replacements: count }), + }); + expect(result).toContain("Successfully"); + expect(await fs.readFile(file, "utf8")).toBe("created"); + } + }); + + it("checks the count before a create-mode no-op", async () => { + const { file, tool } = setup(); + await fs.mkdir(path.dirname(file)); + await fs.writeFile(file, ""); + const write = vi.spyOn(fs, "writeFile"); + const params = { path: file, old_text: "", new_text: "" }; + expect(await tool.execute({ ...params, expected_replacements: 2 })).toContain("expected 2 replacements"); + expect(await tool.execute({ ...params, expected_replacements: 1 })).toContain("No changes made"); + expect(write).not.toHaveBeenCalled(); + }); + + it.each([ + { occurrence: 1, line_hint: null, lineHint: null, replace_all: false }, + { occurrence: null, line_hint: 1, replace_all: false }, + { occurrence: null, line_hint: null, replace_all: true }, + { occurrence: 1, replace_all: false, replaceAll: true, line_hint: null, lineHint: null }, + ])("accepts unused selectors and preserves alias precedence: %j", async (options) => { + const { file, registry } = setup(); + await fs.mkdir(path.dirname(file)); + await fs.writeFile(file, "same\n"); + expect(await registry.execute("edit_file", { + path: file, old_text: "same", new_text: "changed", expected_replacements: 1, ...options, + })).toContain("Successfully edited"); + expect(await fs.readFile(file, "utf8")).toBe("changed\n"); + }); + + it("checks effective aliases after registry type casting", async () => { + const { file, registry } = setup(); + const spies = watchFileAccess(registry.get("edit_file") as EditFileTool); + const result = await registry.execute("edit_file", { + path: file, oldText: "same", newText: "changed", replaceAll: "true", line_hint: null, lineHint: "1", + }); + expect(result).toContain("line_hint cannot be used with replace_all=true"); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + }); + + it("does not use a count assertion to resolve ambiguous matches", async () => { + const { file, registry } = setup(); + await fs.mkdir(path.dirname(file)); + await fs.writeFile(file, "same\nsame\n"); + const result = await registry.execute("edit_file", { + path: file, old_text: "same", new_text: "changed", expected_replacements: 1, + }); + expect(result).toContain("old_text appears 2 times"); + expect(await fs.readFile(file, "utf8")).toBe("same\nsame\n"); + }); +}); diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts index 49db204b4..254b37b99 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts @@ -35,6 +35,7 @@ async function sampleFile(root: string): Promise { afterEach(() => { ReadFileTool.MAX_CHARS = 128_000; vi.restoreAllMocks(); + vi.unstubAllEnvs(); for (const root of roots.splice(0)) { fsSync.rmSync(root, { recursive: true, force: true }); } @@ -299,6 +300,16 @@ describe("EditFileTool", () => { }); describe("filesystem post-write validation", () => { + it("does not run implicit validators for managed writes or edits", async () => { + vi.stubEnv("AGENT_MANAGED_EXECUTION_REQUIRED", "1"); + const root = workspace(); + const target = path.join(root, "managed.json"); + const written = await new WriteFileTool({ workspace: root }).execute({ path: target, content: "invalid json" }); + expect(written).toBe(`Successfully wrote ${target}`); + const edited = await new EditFileTool({ workspace: root }).execute({ path: target, old_text: "invalid", new_text: "still invalid" }); + expect(edited).not.toContain("Lint results:"); + expect(await fs.readFile(target, "utf8")).toBe("still invalid json"); + }); it("skips write_file I/O, file state updates, and lint when bytes are identical", async () => { const root = workspace(); const target = path.join(root, "same.json"); diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/tool-descriptions.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/tool-descriptions.test.ts index cd7e01a81..d7e0f1d07 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/tool-descriptions.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/tool-descriptions.test.ts @@ -4,8 +4,26 @@ import { EditFileTool, ReadFileTool, WriteFileTool } from "../../../../src/core/ import { FindFilesTool, GrepTool } from "../../../../src/core/agent-runtime/tools/search.js"; import { ExecTool } from "../../../../src/core/agent-runtime/tools/shell.js"; import { ListExecSessionsTool, WriteStdinTool } from "../../../../src/core/agent-runtime/tools/exec-session.js"; +import { ToolRegistry } from "../../../../src/core/agent-runtime/tools/registry.js"; describe("coding tool descriptions", () => { + it("documents replace_all restrictions only for true, consistent with parameter validation", () => { + const registry = new ToolRegistry(); + registry.register(new EditFileTool()); + const description = registry.getDefinitions()[0].function.parameters.properties.replace_all.description; + + expect(description).toContain("When true, cannot be combined with non-null occurrence or line_hint, or used in create mode."); + expect(description).toContain("When false, may be combined with occurrence or line_hint, or used in create mode."); + + for (const options of [{ old_text: "same", occurrence: 1 }, { old_text: "same", line_hint: 1 }, { old_text: "" }]) { + const params = { path: "sample.txt", new_text: "changed", ...options }; + expect(registry.prepareCall("edit_file", { ...params, replace_all: false })[2]).toBeNull(); + expect(registry.prepareCall("edit_file", { ...params, replace_all: true })[2]).toContain( + options.old_text === "" ? "create mode" : "cannot be used with replace_all=true", + ); + } + }); + it("steers editing priority", () => { const applyPatch = new ApplyPatchTool().description.toLowerCase(); const editFile = new EditFileTool().description.toLowerCase(); diff --git a/App/memmy-agent/tests/entrypoints/cli/linux-systemd-gateway.test.ts b/App/memmy-agent/tests/entrypoints/cli/linux-systemd-gateway.test.ts index f28321b81..cad475ed7 100644 --- a/App/memmy-agent/tests/entrypoints/cli/linux-systemd-gateway.test.ts +++ b/App/memmy-agent/tests/entrypoints/cli/linux-systemd-gateway.test.ts @@ -406,6 +406,37 @@ describe("Linux systemd Gateway root terminal flow", () => { expect(enableGatewayService).not.toHaveBeenCalled(); }); + it("retries a transient Gateway 5xx response during service startup", async () => { + let serviceRunning = false; + const enableGatewayService = vi.fn(async () => { + serviceRunning = true; + }); + const probes: GatewayProbe[] = [ + { status: "unexpected", detail: "bootstrap HTTP 503" }, + { status: "unexpected", detail: "bootstrap HTTP 503" }, + { status: "unexpected", detail: "bootstrap HTTP 503" }, + { status: "ready" }, + ]; + + await expect(runLinuxRootTerminal({ + platform: "linux", + stdinIsTTY: true, + stdoutIsTTY: true, + systemdGatewayEnabled: true, + loadConfig: configured, + onboardWizard: vi.fn(), + prepareGatewayConfig: vi.fn(async () => configured()), + prepareGatewayEnvironment: async () => false, + enableGatewayService, + probeGateway: vi.fn(async (): Promise => probes.shift() ?? { status: "ready" }), + gatewayServiceMainPid: vi.fn(async () => serviceRunning ? 123 : null), + sleep: async () => undefined, + runInteractive: vi.fn(async () => null), + })).resolves.toBeNull(); + + expect(enableGatewayService).toHaveBeenCalledWith({ restart: false }); + }); + it("leaves source-built Linux, non-Linux, and non-TTY invocations unchanged", async () => { const loadConfig = vi.fn(configured); const runInteractive = vi.fn(async () => null); diff --git a/App/memmy-agent/tests/version.test.ts b/App/memmy-agent/tests/version.test.ts new file mode 100644 index 000000000..2ac597ff6 --- /dev/null +++ b/App/memmy-agent/tests/version.test.ts @@ -0,0 +1,24 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +afterEach(() => { vi.doUnmock("node:fs"); vi.resetModules(); }); +it("packaged runtimes stop at their own manifest without accessing the host manifest", async () => { + vi.resetModules(); + const read = vi.fn((file: string) => { + if (file !== join(packageRoot, "package.json")) throw new Error("EPERM: outside sandbox"); + return JSON.stringify({ name: "@memtensor/memmy-agent", version: "1.2.4" }); + }); + vi.doMock("node:fs", () => ({ existsSync: (file: string) => !file.endsWith("/src/package.json"), readFileSync: read })); + expect((await import("../src/version.js")).VERSION).toBe("1.2.4"); + expect(read).toHaveBeenCalledTimes(1); +}); +it("source checkouts still use the workspace project version", async () => { + vi.resetModules(); + const manifests = new Map([ + [join(packageRoot, "package.json"), { name: "memmy-agent", version: "1.0.0" }], + [join(dirname(dirname(packageRoot)), "package.json"), { name: "memmy-agent", version: "1.2.4", workspaces: [] }], + ]); + vi.doMock("node:fs", () => ({ existsSync: (file: string) => manifests.has(file), readFileSync: (file: string) => JSON.stringify(manifests.get(file)) })); + expect((await import("../src/version.js")).VERSION).toBe("1.2.4"); +}); diff --git a/App/native-computer-use/apps/OpenComputerUseLinux/version.go b/App/native-computer-use/apps/OpenComputerUseLinux/version.go index c041e42e2..2ae63f872 100644 --- a/App/native-computer-use/apps/OpenComputerUseLinux/version.go +++ b/App/native-computer-use/apps/OpenComputerUseLinux/version.go @@ -1,3 +1,3 @@ package main -var version = "1.2.4" +var version = "1.2.401" diff --git a/App/native-computer-use/apps/OpenComputerUseWindows/version.go b/App/native-computer-use/apps/OpenComputerUseWindows/version.go index c041e42e2..2ae63f872 100644 --- a/App/native-computer-use/apps/OpenComputerUseWindows/version.go +++ b/App/native-computer-use/apps/OpenComputerUseWindows/version.go @@ -1,3 +1,3 @@ package main -var version = "1.2.4" +var version = "1.2.401" diff --git a/App/native-computer-use/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/OpenComputerUseVersion.swift b/App/native-computer-use/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/OpenComputerUseVersion.swift index 744231161..c2c59f095 100644 --- a/App/native-computer-use/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/OpenComputerUseVersion.swift +++ b/App/native-computer-use/packages/OpenComputerUseKit/Sources/OpenComputerUseKit/OpenComputerUseVersion.swift @@ -1,6 +1,6 @@ import Foundation -public let openComputerUseVersion = "1.2.4" +public let openComputerUseVersion = "1.2.401" public func resolvedOpenComputerUseVersion(bundle: Bundle = .main) -> String { if let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, diff --git a/App/shell/desktop/electron-builder.win.unsigned.yml b/App/shell/desktop/electron-builder.win.unsigned.yml index 72338d75d..65a555fbc 100644 --- a/App/shell/desktop/electron-builder.win.unsigned.yml +++ b/App/shell/desktop/electron-builder.win.unsigned.yml @@ -33,6 +33,7 @@ files: - "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}" asar: true +npmRebuild: false asarUnpack: - "dist/runtime/memmy-agent/dist/tools/computer-history/win/**" - "dist/runtime/memmy-agent/dist/tools/computer-history/mac/wechat/read_messages.py" diff --git a/App/shell/desktop/electron-builder.win.yml b/App/shell/desktop/electron-builder.win.yml index 79b3abaf7..9eb7eec9e 100644 --- a/App/shell/desktop/electron-builder.win.yml +++ b/App/shell/desktop/electron-builder.win.yml @@ -33,6 +33,7 @@ files: - "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}" asar: true +npmRebuild: false asarUnpack: - "dist/runtime/memmy-agent/dist/tools/computer-history/win/**" - "dist/runtime/memmy-agent/dist/tools/computer-history/mac/wechat/read_messages.py" @@ -90,6 +91,11 @@ win: signAndEditExecutable: true signExecutable: true forceCodeSigning: true + # electron-builder's generated NSIS elevate helper is not used by this + # per-user installer and can intermittently make SimplySign's signtool + # launcher fail with spawn EPERM on the second edition build. + signExts: + - "!elevate.exe" signtoolOptions: signingHashAlgorithms: - sha256 diff --git a/App/shell/desktop/package.json b/App/shell/desktop/package.json index 55df2318e..25ce59e31 100644 --- a/App/shell/desktop/package.json +++ b/App/shell/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@memmy/desktop", - "version": "1.2.4", + "version": "1.2.401", "private": true, "type": "module", "description": "Memmy desktop client.", @@ -17,7 +17,7 @@ "dev": "npm run build:runtime && npm run build:main && electron dist/main/main.js", "dist:mac": "bash ../../../scripts/internal/mac/build-dmg.sh", "dist:mac:unsigned": "MEMMY_SKIP_CODESIGN=1 bash ../../../scripts/internal/mac/build-dmg.sh", - "test": "vitest run tests", + "test": "vitest run --reporter=dot --silent=true tests", "typecheck": "npm run build -w @memmy/desktop-interface && npm run build -w @memmy/backend && tsc -p tsconfig.json --noEmit" }, "dependencies": { @@ -30,7 +30,7 @@ "yaml": "^2.9.0" }, "devDependencies": { - "electron": "38.4.0", + "electron": "43.8.1", "electron-builder": "^26.0.12" } } diff --git a/App/shell/desktop/src/main/browser-browsing-data.ts b/App/shell/desktop/src/main/browser-browsing-data.ts index 7067e7806..0ea89f8ea 100644 --- a/App/shell/desktop/src/main/browser-browsing-data.ts +++ b/App/shell/desktop/src/main/browser-browsing-data.ts @@ -21,7 +21,7 @@ export async function clearInAppBrowserData( ): Promise { if (categories.includes('cookies')) await browserSession.clearStorageData({ storages: ['cookies'] }); if (categories.includes('siteData')) await browserSession.clearStorageData({ - storages: ['filesystem', 'indexdb', 'localstorage', 'websql', 'serviceworkers', 'cachestorage'], + storages: ['filesystem', 'indexdb', 'localstorage', 'serviceworkers', 'cachestorage'], }); if (categories.includes('cache')) await browserSession.clearCache(); if (categories.includes('downloadHistory')) downloads?.clearHistory(); diff --git a/App/shell/desktop/src/main/mac-update-scripts.ts b/App/shell/desktop/src/main/mac-update-scripts.ts index 53d3440a5..eb2f45c1a 100644 --- a/App/shell/desktop/src/main/mac-update-scripts.ts +++ b/App/shell/desktop/src/main/mac-update-scripts.ts @@ -20,24 +20,8 @@ EXPECTED_VERSION=${quotedShellValue(options.expectedVersion ?? "")} CURRENT_VERSION=${quotedShellValue(options.currentVersion ?? "")} version_is_newer() { - /usr/bin/awk -v candidate="$1" -v installed="$2" 'BEGIN { - sub(/\\+.*/, "", candidate); sub(/\\+.*/, "", installed); - at=index(candidate, "-"); bt=index(installed, "-"); - ac=at ? substr(candidate, 1, at-1) : candidate; - bc=bt ? substr(installed, 1, bt-1) : installed; - an=split(ac, a, "."); bn=split(bc, b, "."); - for (i=1; i<=an || i<=bn; i++) { if (a[i]+0>b[i]+0) exit 0; if (a[i]+0an) exit 1; if (i>bn) exit 0; - if ("x" a[i]=="x" b[i]) continue; - av=a[i] ~ /^[0-9]+$/; bv=b[i] ~ /^[0-9]+$/; - if (av && bv) { if (a[i]+0>b[i]+0) exit 0; exit 1 } - if (av) exit 1; if (bv) exit 0; - if ("x" a[i]>"x" b[i]) exit 0; exit 1 - } - exit 1 + LC_ALL=C /usr/bin/awk -v candidate="$1" -v installed="$2" 'BEGIN { + exit !("x" candidate > "x" installed) }' } @@ -64,7 +48,7 @@ validate_app_bundle() { echo "Memmy update version $bundle_version does not follow installed $CURRENT_VERSION" exit 1 fi - if ! /usr/bin/lipo -verify_arch "$EXPECTED_ARCH" "$app_path/Contents/MacOS/$bundle_executable"; then + if ! /usr/bin/lipo "$app_path/Contents/MacOS/$bundle_executable" -verify_arch "$EXPECTED_ARCH"; then echo "Memmy update does not support $EXPECTED_ARCH" exit 1 fi @@ -83,7 +67,7 @@ validate_app_bundle() { exit 1 fi if [[ -n "$EXPECTED_TEAM" ]]; then - local signing_requirement=${quotedShellValue(`anchor apple generic and identifier "cn.memtensor.memmy" and certificate leaf[1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "${options.teamIdentifier ?? ""}"`)} + local signing_requirement=${quotedShellValue(`=anchor apple generic and identifier "cn.memtensor.memmy" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "${options.teamIdentifier ?? ""}"`)} if ! /usr/bin/codesign --verify --deep --strict -R "$signing_requirement" "$app_path"; then echo "Memmy update does not have the installed Apple Developer ID identity" exit 1 diff --git a/App/shell/desktop/src/main/runtime-services.ts b/App/shell/desktop/src/main/runtime-services.ts index c9333c7cb..2162d0598 100644 --- a/App/shell/desktop/src/main/runtime-services.ts +++ b/App/shell/desktop/src/main/runtime-services.ts @@ -1013,8 +1013,7 @@ async function stopOlderBundledMemoryRuntime( const bundledVersion = parseMemoryVersion(bundled.version); const installedVersion = parseMemoryVersion(installed.version); if (!bundledVersion || !installedVersion) return false; - // SemVer precedence keeps prerelease test builds ordered; an identical - // version is replaced only when the bundled build identity changed. + // Match the installer's character ordering; identical versions require a changed build. const difference = compareVersionSegments(bundledVersion, installedVersion); const changedBuild = typeof bundled.buildId === "string" && /^[a-f0-9]{64}$/.test(bundled.buildId) && bundled.buildId !== installed.buildId; diff --git a/App/shell/desktop/src/main/version-compare.ts b/App/shell/desktop/src/main/version-compare.ts index 6f52ff262..feca31396 100644 --- a/App/shell/desktop/src/main/version-compare.ts +++ b/App/shell/desktop/src/main/version-compare.ts @@ -1,53 +1,6 @@ -type ParsedVersion = { - core: number[]; - prerelease: Array | null; -}; - -function parseVersion(value: string): ParsedVersion | null { - const match = value.trim().match(/^v?(\d+(?:\.\d+)*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u); - if (!match) return null; - const core = (match[1] ?? '').split('.').map(Number); - const prerelease = match[2]?.split('.').map(identifier => /^\d+$/u.test(identifier) ? Number(identifier) : identifier) ?? null; - return { core, prerelease }; -} - -/** Compare release versions using SemVer precedence, including prereleases. */ +/** Compare complete release identifiers in character order, so 1.2.401 precedes 1.2.5. */ export function compareVersionSegments(left: string, right: string): number { - const parsedLeft = parseVersion(left); - const parsedRight = parseVersion(right); - if (!parsedLeft || !parsedRight) { - const leftParts = (left.match(/\d+/gu) ?? []).map(Number); - const rightParts = (right.match(/\d+/gu) ?? []).map(Number); - const length = Math.max(leftParts.length, rightParts.length); - for (let index = 0; index < length; index += 1) { - const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); - if (diff !== 0) return diff; - } - return 0; - } - - const coreLength = Math.max(parsedLeft.core.length, parsedRight.core.length); - for (let index = 0; index < coreLength; index += 1) { - const diff = (parsedLeft.core[index] ?? 0) - (parsedRight.core[index] ?? 0); - if (diff !== 0) return diff; - } - if (parsedLeft.prerelease === null && parsedRight.prerelease === null) return 0; - if (parsedLeft.prerelease === null) return 1; - if (parsedRight.prerelease === null) return -1; - - const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); - for (let index = 0; index < length; index += 1) { - const leftIdentifier = parsedLeft.prerelease[index]; - const rightIdentifier = parsedRight.prerelease[index]; - if (leftIdentifier === undefined) return -1; - if (rightIdentifier === undefined) return 1; - if (leftIdentifier === rightIdentifier) continue; - if (typeof leftIdentifier === 'number' && typeof rightIdentifier === 'number') return leftIdentifier - rightIdentifier; - if (typeof leftIdentifier === 'number') return -1; - if (typeof rightIdentifier === 'number') return 1; - return leftIdentifier < rightIdentifier ? -1 : 1; - } - return 0; + return left === right ? 0 : left < right ? -1 : 1; } /** Extract the full version, including a prerelease suffix, from a package name. */ diff --git a/App/shell/desktop/tests/browser-browsing-data.test.ts b/App/shell/desktop/tests/browser-browsing-data.test.ts index 6828d05c8..48e9e9f8d 100644 --- a/App/shell/desktop/tests/browser-browsing-data.test.ts +++ b/App/shell/desktop/tests/browser-browsing-data.test.ts @@ -14,7 +14,7 @@ it('clears only selected data types from the in-app partition and indexes', asyn await clearInAppBrowserData(browserSession as any, history as any, downloads as any, ['siteData', 'cache', 'downloadHistory', 'browsingHistory']); expect(browserSession.clearStorageData).toHaveBeenCalledWith({ storages: [ - 'filesystem', 'indexdb', 'localstorage', 'websql', 'serviceworkers', 'cachestorage', + 'filesystem', 'indexdb', 'localstorage', 'serviceworkers', 'cachestorage', ] }); expect(browserSession.clearCache).toHaveBeenCalledOnce(); expect(history.clear).toHaveBeenCalledOnce(); diff --git a/App/shell/desktop/tests/mac-update-scripts.test.ts b/App/shell/desktop/tests/mac-update-scripts.test.ts index 4501ccf3a..e5093670d 100644 --- a/App/shell/desktop/tests/mac-update-scripts.test.ts +++ b/App/shell/desktop/tests/mac-update-scripts.test.ts @@ -53,12 +53,25 @@ describe.runIf(process.platform === "darwin")("macOS automatic update validation fs.writeFileSync(marker, "pending"); tools = { "/usr/bin/pgrep": mockTool("pgrep", "exit 1"), - "/usr/bin/lipo": mockTool("lipo", 'case " $(cat "$(dirname "$3")/architecture") " in *" $2 "*) exit 0;; *) exit 1;; esac'), + "/usr/bin/lipo": mockTool("lipo", 'if [ "$1" = "-verify_arch" ]; then exit 2; fi; case " $(cat "$(dirname "$1")/architecture") " in *" $3 "*) exit 0;; *) exit 1;; esac'), "/usr/bin/sw_vers": mockTool("sw_vers", "echo 14.0"), "/usr/bin/xattr": mockTool("xattr", "exit 0"), - "/usr/bin/codesign": mockTool("codesign", `for arg in "$@"; do app="$arg"; done -if [ "$1" = -dv ]; then echo "TeamIdentifier=$(cat "$app/team")" >&2; exit 0; fi -for arg in "$@"; do if [ "$arg" = -R ] && [ -f "$app/untrusted-chain" ]; then exit 1; fi; done + "/usr/bin/codesign": mockTool("codesign", `app="" +prev="" +requirement="" +for arg in "$@"; do + if [ "$prev" = "-R" ]; then requirement="$arg"; fi + prev="$arg" + app="$arg" +done +if [ "$1" = "-dv" ]; then echo "TeamIdentifier=$(cat "$app/team")" >&2; exit 0; fi +if [ -n "$requirement" ]; then + case "$requirement" in + "=anchor apple generic "*"field.1.2.840.113635.100.6.1.13"*) ;; + *) exit 2 ;; + esac + if [ -f "$app/untrusted-chain" ]; then exit 1; fi +fi if [ -f "$app/fail-after-replace" ] && [ "$app" = '${destination}' ]; then exit 1; fi [ "$(cat "$app/signature")" = valid ]`), }; @@ -95,15 +108,37 @@ if [ -f "$app/fail-after-replace" ] && [ "$app" = '${destination}' ]; then exit it.each([ ["1.2.1", "1.2.2-rc.1", false], ["1.2.2-rc.1", "1.2.2-rc.1", false], - ["1.2.2-rc.1", "1.2.2", false], - ["1.2.2", "1.2.2-rc.1", true], - ["1.2.2-rc.10", "1.2.2-rc.2", true], - ])("compares package %s against current %s with prerelease precedence", (version, currentVersion, allowed) => { + ["1.2.2-rc.1", "1.2.2", true], + ["1.2.2", "1.2.2-rc.1", false], + ["1.2.2-rc.10", "1.2.2-rc.2", false], + ["1.2.401", "1.2.4", true], + ["1.2.402", "1.2.401", true], + ["1.2.5", "1.2.402", true], + ["1.2.6", "1.2.5", true], + ["1.3.0", "1.2.6", true], + ["1.2.401", "1.2.5", false], + ["1.2.10", "1.2.9", false], + ["1.2.5+build.2", "1.2.5+build.1", true], + ])("compares package %s against current %s by character", (version, currentVersion, allowed) => { fixture(staged, "replacement", { CFBundleShortVersionString: version }); expect(install({ expectedVersion: version, currentVersion }).status === 0).toBe(allowed); expect(fs.readFileSync(path.join(destination, "tag"), "utf8")).toBe(allowed ? "replacement" : "original"); }); + it("stages a 1.2.5 update over 1.2.401", () => { + const dmg = path.join(root, "dmg"); + fixture(path.join(dmg, "Memmy.app"), "replacement", { CFBundleShortVersionString: "1.2.5" }); + tools["/usr/bin/hdiutil"] = mockTool("hdiutil", 'if [ "$1" = attach ]; then cp -R "$2"/. "$6"; fi'); + fs.rmSync(staged, { recursive: true }); + fs.rmSync(`${staged}.ready`); + const helper = path.join(root, "stage.zsh"); + fs.writeFileSync(helper, instrument(createMacDmgUpdateStageScript({ architecture: "arm64", teamIdentifier: "MEMMYTEAM1", expectedVersion: "1.2.5", currentVersion: "1.2.401" }))); + const result = spawnSync("/bin/zsh", [helper, dmg, staged, `${staged}.ready`, log], { encoding: "utf8", timeout: 10_000 }); + expect(result.status, fs.readFileSync(log, "utf8")).toBe(0); + expect(fs.existsSync(`${staged}.ready`)).toBe(true); + expect(fs.readFileSync(path.join(staged, "tag"), "utf8")).toBe("replacement"); + }); + it("rejects a symlink at the destination without changing its target", () => { const original = path.join(root, "original.app"); fs.renameSync(destination, original); @@ -164,3 +199,14 @@ exec /bin/rm "$@"`); expect(fs.readFileSync(path.join(destination, "tag"), "utf8")).toBe("original"); }); }); + +describe("macOS update architecture command", () => { + it("passes the executable to lipo before -verify_arch", () => { + const options = { architecture: "arm64" as const, teamIdentifier: "MEMMYTEAM1", expectedVersion: "1.2.4", currentVersion: "1.2.3" }; + for (const script of [createMacDmgUpdateStageScript(options), createMacDmgUpdateInstallScript(options)]) { + expect(script).toContain('/usr/bin/lipo "$app_path/Contents/MacOS/$bundle_executable" -verify_arch "$EXPECTED_ARCH"'); + expect(script).toContain(`local signing_requirement='=anchor apple generic and identifier "cn.memtensor.memmy" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "MEMMYTEAM1"'`); + expect(script).toContain('codesign --verify --deep --strict -R "$signing_requirement" "$app_path"'); + } + }); +}); diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index 883b41108..78c0b9c13 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -1,5 +1,7 @@ -import { readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { parse as parseYaml } from "yaml"; @@ -249,7 +251,7 @@ describe("desktop packaged runtime boundaries", () => { ); const materializeIndex = source.indexOf('rm -rf "$RUNTIME_MIGRATIONS_DIR"'); const cleanupIndex = source.indexOf('rm -rf "$MIGRATIONS_STAGING_DIR"', stageIndex + 1); - const builderIndex = source.indexOf("npx electron-builder"); + const builderIndex = packagingInvocationIndex(source); expect(stageIndex).toBeGreaterThanOrEqual(0); expect(runtimeInstallIndex).toBeGreaterThan(stageIndex); expect(materializeIndex).toBeGreaterThan(runtimeInstallIndex); @@ -272,7 +274,7 @@ describe("desktop packaged runtime boundaries", () => { expect(winSource).toContain("stateStore.validateMigrationState"); expect(winSource).toContain("Migrations runtime state behavior mismatch"); expect(winSource.lastIndexOf("verify_packaged_windows_unpacked_artifacts")).toBeGreaterThan( - winSource.lastIndexOf("npx electron-builder"), + packagingInvocationIndex(winSource), ); }); @@ -309,32 +311,31 @@ describe("desktop packaged runtime boundaries", () => { expect(source).not.toContain('cp "$MEMORY_DIR/package-lock.json"'); }); - it("installs and verifies better-sqlite3 for both Windows runtimes", () => { + it("installs and verifies SQLite N-API prebuilds for both Windows runtimes", () => { const source = readFileSync(packageWinX64Path, "utf8"); - expect(source).toContain('install_better_sqlite3_win_x64 "$RUNTIME_DIR/memory"'); - expect(source).toContain('install_better_sqlite3_win_x64 "$RUNTIME_DIR/memmy-agent"'); - expect(source).toContain( - '$RUNTIME_DIR/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node', - ); - expect(source).toContain( - '$unpacked_runtime/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node', - ); + expect(source).toContain('npm_ci_win_x64 "$RUNTIME_DIR/memory"'); + expect(source).toContain('npm_ci_win_x64 "$RUNTIME_DIR/memmy-agent"'); + for (const runtime of ["$RUNTIME_DIR/memory", "$RUNTIME_DIR/memmy-agent", "$packaged_memory_runtime", "$unpacked_runtime/memmy-agent"]) { + expect(source).toContain(`${runtime}/node_modules/better-sqlite3/prebuilds/win32-x64.node`); + } + expect(source).toContain("verify_windows_x64_native_module"); + expect(source).not.toContain("better-sqlite3/build/Release/better_sqlite3.node"); const agentDependenciesIndex = source.indexOf('npm_ci_win_x64 "$RUNTIME_DIR/memmy-agent"'); - const agentInstallIndex = source.indexOf( - 'install_better_sqlite3_win_x64 "$RUNTIME_DIR/memmy-agent"', + const pruneIndex = source.indexOf( + "\nprune_windows_better_sqlite3_artifacts\n", agentDependenciesIndex, ); - const agentVerifyIndex = source.indexOf("verify_windows_agent_native_artifacts", agentInstallIndex); + const agentVerifyIndex = source.indexOf("verify_windows_agent_native_artifacts", pruneIndex); const agentSmokeIndex = source.indexOf( 'verify_windows_better_sqlite3_runtime "$RUNTIME_DIR/memmy-agent"', agentVerifyIndex, ); - const builderIndex = source.lastIndexOf("npx electron-builder"); + const builderIndex = packagingInvocationIndex(source); const finalVerifyIndex = source.lastIndexOf("verify_packaged_windows_unpacked_artifacts"); expect(agentDependenciesIndex).toBeGreaterThanOrEqual(0); - expect(agentInstallIndex).toBeGreaterThan(agentDependenciesIndex); - expect(agentVerifyIndex).toBeGreaterThan(agentInstallIndex); + expect(pruneIndex).toBeGreaterThan(agentDependenciesIndex); + expect(agentVerifyIndex).toBeGreaterThan(pruneIndex); expect(agentSmokeIndex).toBeGreaterThan(agentVerifyIndex); expect(builderIndex).toBeGreaterThan(agentSmokeIndex); expect(finalVerifyIndex).toBeGreaterThan(builderIndex); @@ -1149,8 +1150,8 @@ describe("desktop packaged runtime boundaries", () => { expect(packageSource).not.toContain(['create_cli_launcher "$CLI_BIN_DIR/', 'memmy-agent', '"'].join("")); expect(packageSource).not.toContain(['ln -sf "$SCRIPT_DIR/', 'memmy-agent', '"'].join("")); expect(windowsPackageSource).toContain('create_windows_cli_launcher "$CLI_BIN_DIR/memmy.cmd"'); - expect(windowsPackageSource).toContain('require_packaged_runtime_file "$DESKTOP_DIR/release/win-unpacked/resources/cli/memmy-memory.cmd"'); - expect(windowsPackageSource).toContain('require_packaged_runtime_file "$DESKTOP_DIR/release/win-unpacked/resources/cli/memmy.cmd"'); + expect(windowsPackageSource).toContain('require_packaged_runtime_file "$UNPACKED_DIR/resources/cli/memmy-memory.cmd"'); + expect(windowsPackageSource).toContain('require_packaged_runtime_file "$UNPACKED_DIR/resources/cli/memmy.cmd"'); expect(windowsPackageSource).toContain('for %%I in ("%RESOURCES_DIR%\\..") do set "APP_DIR=%%~fI"'); expect(windowsPackageSource).toContain('set "APP_EXEC=%APP_DIR%\\Memmy.exe"'); expect(windowsPackageSource).not.toContain('set "APP_EXEC=%RESOURCES_DIR%\\Memmy.exe"'); @@ -1694,7 +1695,7 @@ describe("desktop packaged runtime boundaries", () => { expect(source).not.toContain("memory-service/config.yaml"); }); - it("packages Memory from its own workspace with an Electron-rebuilt sqlite addon", () => { + it("packages Memory from its own workspace with the target SQLite N-API prebuild", () => { const source = readFileSync(packageMacDmgPath, "utf8"); expect(source).toContain('MEMORY_DIR="$ROOT_DIR/Memory"'); @@ -1721,8 +1722,19 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('npm ci --prefix "$RUNTIME_DIR/memory" --omit=dev --install-links --os=darwin --cpu="$TARGET_CPU"'); expect(source).not.toContain('MEMORY_RUNTIME_CONTRACTS_DIR'); expect(source).not.toContain('MEMORY_RUNTIME_MIGRATIONS_DIR'); - expect(source).toContain("node_modules/.bin/electron-rebuild"); - expect(source).toContain('-m "$RUNTIME_DIR/memory"'); + expect(source).toContain('prune_better_sqlite3_build_artifacts "$module_dir" "$TARGET_CPU"'); + expect(source).toContain('require_packaged_runtime_file "$RUNTIME_DIR/memory/node_modules/better-sqlite3/prebuilds/darwin-$target_cpu.node"'); + expect(source).toContain('require_packaged_runtime_file "$packaged_memory_runtime/node_modules/better-sqlite3/prebuilds/darwin-$target_cpu.node"'); + const pruneIndex = source.lastIndexOf('prune_mac_runtime_artifacts "$TARGET_CPU"'); + const nativeVerifyIndex = source.lastIndexOf('verify_mac_memory_native_artifacts "$TARGET_CPU"'); + const builderIndex = packagingInvocationIndex(source); + const finalVerifyIndex = source.lastIndexOf('verify_packaged_mac_unpacked_artifacts "$TARGET_CPU"'); + expect(pruneIndex).toBeGreaterThanOrEqual(0); + expect(nativeVerifyIndex).toBeGreaterThan(pruneIndex); + expect(builderIndex).toBeGreaterThan(nativeVerifyIndex); + expect(finalVerifyIndex).toBeGreaterThan(builderIndex); + expect(source).not.toContain("node_modules/.bin/electron-rebuild"); + expect(source).not.toContain("better-sqlite3/build/Release/better_sqlite3.node"); expect(source).not.toContain('cp -R "$ROOT_DIR/dist/src" "$RUNTIME_DIR/memory/src"'); }); @@ -1786,7 +1798,7 @@ describe("desktop packaged runtime boundaries", () => { expect(winSource).toContain('onnxruntime_dir="$(dirname "$onnxruntime_node")"'); expect(winSource).toContain("onnxruntime.dll"); expect(winSource).toContain("sharp-win32-x64/lib"); - expect(winSource).toContain("win-unpacked/resources/app.asar.unpacked/dist/runtime"); + expect(winSource).toContain("$UNPACKED_DIR/resources/app.asar.unpacked/dist/runtime"); expect(winSource).toContain("conpty/OpenConsole.exe"); expect(winSource).toContain("sqlite-vec-windows-x64/vec0.*"); }); @@ -1805,7 +1817,7 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('$packaged_embedding_model/tokenizer.json'); expect(source).toContain('$packaged_embedding_model/onnx/model_quantized.onnx'); expect(source.indexOf("prepare-embedding-model.mjs")).toBeLessThan( - source.indexOf("npx electron-builder") + packagingInvocationIndex(source) ); } expect(prepareEmbeddingModelSource).toContain('const fallbackRemoteHost = "https://hf-mirror.com/";'); @@ -1834,7 +1846,7 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('rm -f "$RUNTIME_DIR/memmy-agent/dist/skills/README.md"'); expect(source.indexOf('prune_node_modules_non_runtime_files "$RUNTIME_DIR"')).toBeLessThan( - source.indexOf("npx electron-builder"), + packagingInvocationIndex(source), ); }); @@ -1887,10 +1899,10 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain("to_node_readable_path"); expect(source).toContain("cygpath -w"); expect(source).toContain('DESKTOP_VERSION="$(read_package_version "$DESKTOP_DIR/package.json")"'); - expect(source).toContain( - 'electron_version="${MEMMY_ELECTRON_VERSION:-$(read_package_version "$DESKTOP_DIR/node_modules/electron/package.json")}"' - ); + expect(source).toContain('MEMORY_VERSION="$(read_package_version "$MEMORY_DIR/package.json")"'); + expect(source).toContain('package_json_path="$(to_node_readable_path "$1")"'); expect(source).not.toContain("require('$DESKTOP_DIR/package.json')"); + expect(source).not.toContain("require('$MEMORY_DIR/package.json')"); expect(source).not.toContain("require('$DESKTOP_DIR/node_modules/electron/package.json')"); }); @@ -1915,9 +1927,9 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('source.includes(marker)'); expect(source).toContain('source.replace(original, replacement)'); expect(source).toContain('patch_electron_builder_nsis_refresh'); - expect(source.indexOf("patch_electron_builder_nsis_refresh")).toBeLessThan( - source.indexOf('npx electron-builder "${BUILDER_ARGS[@]}" --win nsis --x64') - ); + const patchIndex = source.lastIndexOf("\npatch_electron_builder_nsis_refresh\n"); + expect(patchIndex).toBeGreaterThanOrEqual(0); + expect(patchIndex).toBeLessThan(packagingInvocationIndex(source)); }); it("reuses the installed Electron dist during Windows packaging", () => { @@ -1929,15 +1941,46 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('BUILDER_ARGS+=(--config.electronDist="$ELECTRON_DIST")'); }); - it("retries flaky native prebuild downloads during Windows packaging", () => { + it("keeps only target SQLite N-API prebuilds without Electron ABI downloads", () => { const source = readFileSync(packageWinX64Path, "utf8"); + const macSource = readFileSync(packageMacDmgPath, "utf8"); expect(source).toContain("run_with_retries"); - expect(source).toContain("run_with_retries 3 ../.bin/prebuild-install"); - expect(source).toContain("install_better_sqlite3_prebuild_with_download_fallback"); - expect(source).toContain("--verbose 2>&1"); expect(source).toContain("Invoke-WebRequest"); - expect(source).toContain('prebuild_file="prebuilds/$(basename "$prebuild_url")"'); + expect(source).toContain("curl.exe -L --fail --retry 3"); + expect(source).not.toContain("prebuild-install"); + expect(source).not.toContain("install_better_sqlite3_prebuild_with_download_fallback"); + + const windowsPrune = source.match(/^prune_better_sqlite3_win_artifacts\(\) \{\n[\s\S]*?^\}/m)?.[0]; + const macPrune = macSource.match(/^prune_better_sqlite3_build_artifacts\(\) \{\n[\s\S]*?^\}/m)?.[0]; + expect(windowsPrune).toBeDefined(); + expect(macPrune).toBeDefined(); + const root = mkdtempSync(join(tmpdir(), "memmy-sqlite-napi-boundary-")); + try { + for (const target of ["win32-x64", "darwin-arm64", "darwin-x64"]) { + for (const runtime of ["memory", "memmy-agent"]) { + const moduleDir = join(root, target, runtime, "node_modules", "better-sqlite3"); + const prebuilds = join(moduleDir, "prebuilds"); + mkdirSync(prebuilds, { recursive: true }); + for (const name of ["win32-x64.node", "win32-arm64.node", "darwin-arm64.node", "darwin-x64.node", "linux-x64.node"]) { + writeFileSync(join(prebuilds, name), name); + } + for (const directory of ["build", "deps", "src"]) mkdirSync(join(moduleDir, directory)); + writeFileSync(join(moduleDir, "package.json"), '{"name":"better-sqlite3"}\n'); + + const prune = target === "win32-x64" ? windowsPrune : macPrune; + const command = target === "win32-x64" ? "prune_better_sqlite3_win_artifacts" : "prune_better_sqlite3_build_artifacts"; + execFileSync("bash", ["-c", `${prune}\n${command} "$1" "$2"`, "--", moduleDir, target.split("-")[1]]); + + expect(readdirSync(prebuilds)).toEqual([`${target}.node`]); + expect(readFileSync(join(prebuilds, `${target}.node`), "utf8")).toBe(`${target}.node`); + for (const directory of ["build", "deps", "src"]) expect(existsSync(join(moduleDir, directory))).toBe(false); + expect(readFileSync(join(moduleDir, "package.json"), "utf8")).toBe('{"name":"better-sqlite3"}\n'); + } + } + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("keeps Windows packaging from mutating memmy-agent dependency locks", () => { @@ -1960,6 +2003,8 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('"$RUNTIME_DIR/memmy-agent/dist/native-computer-history/windows/amd64/memmy-computer-history.exe"'); expect(source).toContain('build-memmy-computer-history.sh'); expect(source).toContain('verify-computer-history-runtime.mjs'); + expect(source).toContain('if [ "$PACKAGE_SIGNING" = "unsigned" ]; then'); + expect(source).toContain('Signed package: skipping byte-for-byte comparison'); expect(source).toContain('"Windows 11 native History recorder"'); }); @@ -1970,7 +2015,8 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain("desktop-edition.json"); expect(source).toContain("write-desktop-edition-manifest.mjs"); expect(source).toContain('--signing "$PACKAGE_SIGNING"'); - expect(source).toContain('FINAL_EXE="$DESKTOP_DIR/release/Memmy-$DESKTOP_VERSION-win32-$PACKAGE_ARCH-$PACKAGE_EDITION-$PACKAGE_SIGNING.exe"'); + expect(source).toContain('RELEASE_DIR="$DESKTOP_DIR/release"'); + expect(source).toContain('FINAL_EXE="$RELEASE_DIR/Memmy-$DESKTOP_VERSION-win32-$PACKAGE_ARCH-$PACKAGE_EDITION-$PACKAGE_SIGNING.exe"'); expect(source).toContain('ARTIFACT_NAME="Memmy-$DESKTOP_VERSION-win32-$PACKAGE_ARCH-$PACKAGE_EDITION-$PACKAGE_SIGNING.\\${ext}"'); expect(source).toContain('BUILDER_ARGS+=(--config.extraMetadata.version="$DESKTOP_VERSION")'); expect(source).toContain('npx electron-builder "${BUILDER_ARGS[@]}" --win nsis --x64 "$@" --config.artifactName="$ARTIFACT_NAME"'); @@ -1978,6 +2024,24 @@ describe("desktop packaged runtime boundaries", () => { expect(source).not.toContain("mv -f"); }); + it("does not ask SimplySign to sign the unused per-user NSIS elevate helper", () => { + const config = parseYaml(readFileSync(winElectronBuilderPath, "utf8")); + + expect(config.win.signExecutable).toBe(true); + expect(config.win.forceCodeSigning).toBe(true); + expect(config.win.signExts).toContain("!elevate.exe"); + expect(config.nsis.perMachine).toBe(false); + expect(config.nsis.allowElevation).toBe(false); + }); + + it("extracts the NSIS uninstaller without executing an unsigned intermediate installer on Windows", () => { + const source = readFileSync(packageWinX64Path, "utf8"); + + expect(source).toContain("patch_electron_builder_nsis_uninstaller_extraction"); + expect(source).toContain("UninstallerReader.exec(installerPath, uninstallerPath)"); + expect(source).toContain("Memmy: extract the NSIS uninstaller before executing the unsigned installer"); + }); + it("fails closed when requested, source, builder, or staged runtime versions diverge", () => { const publicMacSource = readFileSync(packageMacPath, "utf8"); const publicWinSource = readFileSync(packageWinPath, "utf8"); @@ -2052,7 +2116,7 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain("verify-packaged-asar.mjs"); expect(source).toContain("verify_packaged_runtime_config_boundary"); expect(source.indexOf("prune-runtime-env-files.mjs")).toBeLessThan( - source.indexOf("npx electron-builder"), + packagingInvocationIndex(source), ); } expect(writerSource).toContain("cloudService"); @@ -2100,7 +2164,7 @@ describe("desktop packaged runtime boundaries", () => { source.lastIndexOf("verify_windows_agent_html_lint_runtime"), ); expect(source.lastIndexOf("verify_pruned_windows_runtime")).toBeLessThan( - source.indexOf("npx electron-builder"), + packagingInvocationIndex(source), ); }); }); @@ -2113,6 +2177,17 @@ function normalizeLineEndings(source: string): string { return source.replace(/\r\n/g, "\n"); } +function packagingInvocationIndex(source: string): number { + // Windows declares electron-builder inside a signing wrapper before the main flow. + // Match its top-level invocation; macOS still invokes electron-builder directly. + const invocation = source.includes("run_electron_builder_with_signing_recovery()") + ? /^run_electron_builder_with_signing_recovery "\$@"\r?$/gm + : /^npx electron-builder [^\r\n]+\r?$/gm; + const matches = [...source.matchAll(invocation)]; + expect(matches).toHaveLength(1); + return matches[0]!.index; +} + function extractFunctionSource(source: string, declaration: string): string { const start = source.indexOf(declaration); expect(start).toBeGreaterThanOrEqual(0); diff --git a/App/shell/desktop/tests/runtime-memory-upgrade.test.ts b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts index b0953713b..5c55e866e 100644 --- a/App/shell/desktop/tests/runtime-memory-upgrade.test.ts +++ b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts @@ -290,8 +290,9 @@ describe("bundled Memory upgrades", () => { }); it.each([ - ["2.1.1-rc.2", "2.1.1-rc.10"], - ["2.1.1-rc.10", "2.1.1"], + ["1.2.401", "1.2.5"], + ["2.1.1-rc.10", "2.1.1-rc.2"], + ["2.1.1", "2.1.1-rc.10"], ["2.1.1", "2.1.2-rc.1"], ])("upgrades the owned Memory runtime from %s to %s", async (version, bundledVersion) => { const running = await fixture({ version, bundledVersion }); @@ -302,8 +303,9 @@ describe("bundled Memory upgrades", () => { }); it.each([ - ["2.1.1-rc.10", "2.1.1-rc.2"], - ["2.1.1", "2.1.1-rc.10"], + ["1.2.5", "1.2.401"], + ["2.1.1-rc.2", "2.1.1-rc.10"], + ["2.1.1-rc.10", "2.1.1"], ["2.1.2-rc.1", "2.1.1"], ])("keeps the newer owned Memory runtime %s when Desktop bundles %s", async (version, bundledVersion) => { const running = await fixture({ version, bundledVersion }); diff --git a/App/shell/desktop/tests/version-compare.test.ts b/App/shell/desktop/tests/version-compare.test.ts index 841085d26..a6c2056dd 100644 --- a/App/shell/desktop/tests/version-compare.test.ts +++ b/App/shell/desktop/tests/version-compare.test.ts @@ -2,20 +2,25 @@ import { describe, expect, it } from 'vitest'; import { compareVersionSegments, parseUpdatePackageVersion } from '../src/main/version-compare.js'; describe('compareVersionSegments', () => { - it('orders a prerelease below its stable release', () => { - expect(compareVersionSegments('1.1.9-rc.1', '1.1.9')).toBeLessThan(0); - expect(compareVersionSegments('1.1.9', '1.1.9-rc.1')).toBeGreaterThan(0); + it.each([ + ['1.2.4', '1.2.401'], + ['1.2.401', '1.2.402'], + ['1.2.402', '1.2.5'], + ['1.2.5', '1.2.6'], + ['1.2.6', '1.3.0'], + ['1.2.10', '1.2.9'], + ['1.10.0', '1.9.9'], + ['1.1.9', '1.1.9-rc.1'], + ['1.1.9-rc.10', '1.1.9-rc.2'], + ['1.1.9-alpha', '1.1.9-beta'], + ['1.2.3+build.5', '1.2.3+build.8'], + ])('orders %s before %s by character', (older, newer) => { + expect(compareVersionSegments(older, newer)).toBe(-1); + expect(compareVersionSegments(newer, older)).toBe(1); }); - it('orders prerelease identifiers according to SemVer', () => { - expect(compareVersionSegments('1.1.9-rc.2', '1.1.9-rc.10')).toBeLessThan(0); - expect(compareVersionSegments('1.1.9-alpha', '1.1.9-beta')).toBeLessThan(0); - expect(compareVersionSegments('1.1.9-1', '1.1.9-alpha')).toBeLessThan(0); - }); - - it('ignores build metadata and preserves ordinary numeric comparisons', () => { - expect(compareVersionSegments('1.2.3+build.5', '1.2.3+build.8')).toBe(0); - expect(compareVersionSegments('1.2.4', '1.2.3')).toBeGreaterThan(0); + it('only returns equality for identical identifiers', () => { + expect(compareVersionSegments('1.2.401', '1.2.401')).toBe(0); }); it('keeps prerelease metadata when parsing downloaded package names', () => { diff --git a/App/shell/desktop/tests/windows-update-validation.test.ts b/App/shell/desktop/tests/windows-update-validation.test.ts index 1aaa80a8b..4227102a6 100644 --- a/App/shell/desktop/tests/windows-update-validation.test.ts +++ b/App/shell/desktop/tests/windows-update-validation.test.ts @@ -96,7 +96,7 @@ describe("Windows updater identity and exact version checks", () => { expect(() => validateWindowsUpdateInspection(report, options)).toThrow("not a Memmy installer"); }); - it.each(["1.2.1", "1.2.0", "1.2.1-rc.10"])("rejects update version %s from installed 1.2.1", (productVersion) => { + it.each(["1.2.1", "1.2.0", "1.2.0-rc.10"])("rejects update version %s from installed 1.2.1", (productVersion) => { const report = inspection(); report.installer.productVersion = productVersion; expect(() => validateWindowsUpdateInspection(report, { ...options, expectedVersion: undefined })).toThrow("Refusing a Windows update"); @@ -109,12 +109,29 @@ describe("Windows updater identity and exact version checks", () => { expect(() => validateWindowsUpdateInspection(report, { ...options, currentVersion: "1.2.0", expectedVersion: "1.2.1" })).toThrow("does not match expected"); }); - it("preserves prerelease identity and orders numeric RC versions correctly", () => { + it.each([ + ["1.2.4", "1.2.401"], + ["1.2.401", "1.2.402"], + ["1.2.402", "1.2.5"], + ["1.2.5", "1.2.6"], + ["1.2.6", "1.3.0"], + ["1.2.10", "1.2.9"], + ])("accepts %s to %s but rejects the reverse update", (older, newer) => { + const report = inspection(); + report.current.fileVersion = older; + report.installer.productVersion = newer; + expect(validateWindowsUpdateInspection(report, { ...options, currentVersion: older, expectedVersion: newer })).toMatchObject({ version: newer }); + report.current.fileVersion = newer; + report.installer.productVersion = older; + expect(() => validateWindowsUpdateInspection(report, { ...options, currentVersion: newer, expectedVersion: older })).toThrow("Refusing a Windows update"); + }); + + it("preserves prerelease identity and orders RC versions by character", () => { const report = inspection(); report.current.productVersion = "1.2.2.0"; - report.current.fileVersion = "1.2.2-rc.2"; - report.installer.productVersion = "1.2.2-rc.10"; - expect(validateWindowsUpdateInspection(report, { ...options, currentVersion: "1.2.2-rc.2", expectedVersion: "1.2.2-rc.10" })).toMatchObject({ version: "1.2.2-rc.10" }); + report.current.fileVersion = "1.2.2-rc.10"; + report.installer.productVersion = "1.2.2-rc.2"; + expect(validateWindowsUpdateInspection(report, { ...options, currentVersion: "1.2.2-rc.10", expectedVersion: "1.2.2-rc.2" })).toMatchObject({ version: "1.2.2-rc.2" }); }); it("allows a stable executable to install an exact RC without interpreting numeric ProductVersion as its release", () => { diff --git a/Memory/package.json b/Memory/package.json index 634f483b6..71f42a4b5 100644 --- a/Memory/package.json +++ b/Memory/package.json @@ -18,7 +18,7 @@ "serve:dev": "tsx src/server/index.ts", "preserve:dev": "npm run integration:build", "worker:run": "node dist/src/cli/index.js raw POST /worker/run", - "test": "vitest run --dir tests", + "test": "vitest run --reporter=dot --dir tests", "typecheck": "npm run build -w @memmy/agent-source-core && tsc -p tsconfig.json --noEmit && tsc -p viewer/tsconfig.json --noEmit", "viewer:build": "vite build --config viewer/vite.config.ts", "viewer:dev": "vite --config viewer/vite.config.ts --host 127.0.0.1", @@ -39,7 +39,7 @@ "dependencies": { "@huggingface/transformers": "^3.8.0", "@memmy/agent-source-core": "0.0.0", - "better-sqlite3": "^12.6.3", + "better-sqlite3": "^13.0.3", "dotenv": "^16.6.1", "fast-xml-parser": "^5.8.0", "fzstd": "^0.1.1", @@ -57,7 +57,7 @@ "@preact/signals": "^2.8.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.9.1", - "@yao-pkg/pkg": "^6.22.0", + "@yao-pkg/pkg": "6.24.0", "esbuild": "^0.27.4", "preact": "^10.27.2", "tsx": "^4.22.3", diff --git a/Memory/src/cli/runtime-installer.ts b/Memory/src/cli/runtime-installer.ts index 22309f761..cc649ebbc 100644 --- a/Memory/src/cli/runtime-installer.ts +++ b/Memory/src/cli/runtime-installer.ts @@ -1003,35 +1003,11 @@ export function runtimeTarget(platform: NodeJS.Platform, arch: string): string { return `${platformName}-${archName}`; } +/** Use the same character ordering as Desktop without relaxing manifest validation. */ export function compareVersions(left: string, right: string): number { - const parse = (value: string) => { - const match = value.match(SEMANTIC_VERSION_PATTERN); - if (!match) throw new Error(`invalid semantic version: ${value}`); - return { numbers: [match[1]!, match[2]!, match[3]!], prerelease: match[4]?.split(".") }; - }; - const compareNumericIdentifiers = (a: string, b: string) => Math.sign(a.length - b.length) || (a < b ? -1 : a > b ? 1 : 0); - const a = parse(left); const b = parse(right); - for (let index = 0; index < 3; index += 1) { - const delta = compareNumericIdentifiers(a.numbers[index]!, b.numbers[index]!); - if (delta !== 0) return delta; - } - if (!a.prerelease && !b.prerelease) return 0; - if (!a.prerelease) return 1; - if (!b.prerelease) return -1; - for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index += 1) { - const leftIdentifier = a.prerelease[index]; - const rightIdentifier = b.prerelease[index]; - if (leftIdentifier === undefined) return -1; - if (rightIdentifier === undefined) return 1; - if (leftIdentifier === rightIdentifier) continue; - const leftNumeric = /^\d+$/.test(leftIdentifier); - const rightNumeric = /^\d+$/.test(rightIdentifier); - if (leftNumeric && rightNumeric) return compareNumericIdentifiers(leftIdentifier, rightIdentifier); - if (leftNumeric) return -1; - if (rightNumeric) return 1; - return leftIdentifier < rightIdentifier ? -1 : 1; - } - return 0; + if (!validVersion(left)) throw new Error(`invalid semantic version: ${left}`); + if (!validVersion(right)) throw new Error(`invalid semantic version: ${right}`); + return left === right ? 0 : left < right ? -1 : 1; } function validVersion(value: unknown): value is string { return typeof value === "string" && SEMANTIC_VERSION_PATTERN.test(value); } diff --git a/Memory/src/cli/scripts/build-runtime.mjs b/Memory/src/cli/scripts/build-runtime.mjs index 81e800286..8f477d50f 100755 --- a/Memory/src/cli/scripts/build-runtime.mjs +++ b/Memory/src/cli/scripts/build-runtime.mjs @@ -3,7 +3,7 @@ import { npmInvocation } from "../../../../scripts/internal/shared/npm-invocatio import { memoryRuntimeBuildIdentity } from "../../../../scripts/internal/shared/memory-runtime-build-identity.mjs"; import { createHash } from "node:crypto"; import { createReadStream, existsSync } from "node:fs"; -import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve, sep } from "node:path"; import { spawnSync } from "node:child_process"; @@ -159,8 +159,9 @@ async function verifyRuntimeDependencies(root, target, platform, arch, workspace for (const name of workspaceDependencies) { if (!existsSync(join(root, "node_modules", name, "package.json"))) throw new Error(`workspace package is missing from the runtime bundle: ${name}`); } - const nativeFiles = await findFiles(join(root, "node_modules", "better-sqlite3"), (name) => name === "better_sqlite3.node"); - if (nativeFiles.length === 0) throw new Error(`better-sqlite3 native module is missing for ${target}`); + // better-sqlite3 13 ships N-API prebuilds instead of compiling build/Release/better_sqlite3.node. + const sqlitePrebuild = join(root, "node_modules", "better-sqlite3", "prebuilds", `${npmPlatform(platform)}-${arch}.node`); + if (!existsSync(sqlitePrebuild)) throw new Error(`better-sqlite3 native module is missing for ${target}`); const sqliteVecPackage = join(root, "node_modules", `sqlite-vec-${target}`); if (!existsSync(sqliteVecPackage)) throw new Error(`sqlite-vec native package is missing for ${target}`); const sharpPackage = join(root, "node_modules", "@img", `sharp-${npmPlatform(platform)}-${arch}`); @@ -174,17 +175,6 @@ async function verifyEmbeddingModel(root) { } } -async function findFiles(root, predicate) { - if (!existsSync(root)) return []; - const result = []; - for (const entry of await readdir(root, { withFileTypes: true })) { - const path = join(root, entry.name); - if (entry.isDirectory()) result.push(...await findFiles(path, predicate)); - else if (predicate(entry.name)) result.push(path); - } - return result; -} - async function updateReleaseManifest(outputRoot, version, target, descriptor) { const path = join(outputRoot, "memory-release.json"); let release = { version, protocolVersion: 1, assets: {} }; diff --git a/Memory/src/cli/scripts/build-standalone.mjs b/Memory/src/cli/scripts/build-standalone.mjs index 0f7b29e13..a6983c595 100755 --- a/Memory/src/cli/scripts/build-standalone.mjs +++ b/Memory/src/cli/scripts/build-standalone.mjs @@ -12,10 +12,9 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; -// `pkg` ships prebuilt Node binaries; these two constants must describe the same -// release, because better-sqlite3 prebuilds are keyed by Node's ABI version. -const PKG_NODE_RANGE = "node22"; -const PKG_NODE_VERSION = "22.23.2"; +// Pin the patched runtime: a major-only range can select an older binary +// from pkg-fetch's release table. +const PKG_NODE_RANGE = "node24.22.1"; const require = createRequire(import.meta.url); const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -57,24 +56,41 @@ try { await rm(temporaryRoot, { recursive: true, force: true }); } -// better-sqlite3 links against Node's V8 ABI, and the bundle carries whichever -// prebuild matched the build host. Swap it for the one `pkg`'s Node can load. -// onnxruntime-node and sharp use N-API, so only their platform slice matters. +// better-sqlite3 13 ships N-API prebuilds inside the package, one file per platform and +// architecture, so nothing has to be fetched for the runtime's ABI; drop the other slices. +// onnxruntime-node and sharp use N-API too, so only their platform slice matters. async function alignNativeModulesToPkgRuntime(root) { - const betterSqlite3 = join(root, "node_modules", "better-sqlite3"); - await rm(join(betterSqlite3, "build", "Release"), { recursive: true, force: true }); - run("node", [require.resolve("prebuild-install/bin.js", { paths: [betterSqlite3] }), "--runtime=node", `--target=${PKG_NODE_VERSION}`, `--platform=${npmPlatform(platform)}`, `--arch=${arch}`], betterSqlite3); - if (!existsSync(join(betterSqlite3, "build", "Release", "better_sqlite3.node"))) { - throw new Error(`better-sqlite3 has no Node ${PKG_NODE_VERSION} prebuild for ${target}`); + const prebuilds = join(root, "node_modules", "better-sqlite3", "prebuilds"); + // better-sqlite3 resolves its binary at runtime and decides between the glibc and musl + // build from process.report, which a `pkg` binary does not always populate. Keep both + // Linux variants so a wrong guess still finds a file instead of falling through to the + // node-gyp path, which 13.x no longer produces. + const wanted = npmPlatform(platform) === "linux" + ? [`linux-${arch}.node`, `linuxmusl-${arch}.node`] + : [`${npmPlatform(platform)}-${arch}.node`]; + for (const name of wanted) { + if (!existsSync(join(prebuilds, name))) { + throw new Error(`better-sqlite3 has no ${name} prebuild for ${target}`); + } } + await Promise.all( + (await readdir(prebuilds)) + .filter((entry) => !wanted.includes(entry)) + .map((entry) => rm(join(prebuilds, entry), { force: true })), + ); const onnxBin = join(root, "node_modules", "onnxruntime-node", "bin", "napi-v3"); - for (const entry of await readdir(onnxBin)) { - if (entry !== npmPlatform(platform)) await rm(join(onnxBin, entry), { recursive: true, force: true }); - } - for (const entry of await readdir(join(onnxBin, npmPlatform(platform)))) { - if (entry !== arch) await rm(join(onnxBin, npmPlatform(platform), entry), { recursive: true, force: true }); - } + await Promise.all( + (await readdir(onnxBin)) + .filter((entry) => entry !== npmPlatform(platform)) + .map((entry) => rm(join(onnxBin, entry), { recursive: true, force: true })), + ); + const platformBin = join(onnxBin, npmPlatform(platform)); + await Promise.all( + (await readdir(platformBin)) + .filter((entry) => entry !== arch) + .map((entry) => rm(join(platformBin, entry), { recursive: true, force: true })), + ); } // `scripts` are parsed and snapshotted as code; `assets` are copied verbatim. diff --git a/Memory/src/service/project-environment/local-scanner.ts b/Memory/src/service/project-environment/local-scanner.ts index 4a21590b5..a298c9088 100644 --- a/Memory/src/service/project-environment/local-scanner.ts +++ b/Memory/src/service/project-environment/local-scanner.ts @@ -3,11 +3,10 @@ import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { access, lstat, readFile, readdir, realpath, stat } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; -import { createRequire } from "node:module"; import { delimiter, isAbsolute, parse, relative, resolve, sep } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; -import type { Ignore } from "ignore"; +import ignore, { type Ignore } from "ignore"; import { canonicalJson, isLocalWorkspaceUri, @@ -30,7 +29,7 @@ import type { } from "./types.js"; const execFileAsync = promisify(execFile); -const createIgnore = createRequire(import.meta.url)("ignore") as () => Ignore; +const createIgnore: () => Ignore = ignore; const FIXED_EXCLUDES = new Set([ ".git", "node_modules", "vendor", ".venv", "venv", "env", "dist", "build", "out", diff --git a/Memory/src/service/read-model/panel-read.ts b/Memory/src/service/read-model/panel-read.ts index 8a2586826..7595f3717 100644 --- a/Memory/src/service/read-model/panel-read.ts +++ b/Memory/src/service/read-model/panel-read.ts @@ -9,7 +9,9 @@ import type { EvolutionJobRecord, L3WorldModelScopeRecord, RawTurnRecord, - Repositories + RecallEventRecord, + Repositories, + SkillTrialRecord } from "../../storage/repositories.js"; import { detailSummaryForMemory } from "./memory.js"; import type { @@ -51,6 +53,7 @@ import { const PANEL_ITEMS_PAGE_SIZE = 20; const PANEL_DAILY_ACTIVITY_DAYS = 371; +const PANEL_USAGE_EVENT_LIMIT = 10_000; type PanelChange = { seq: number; @@ -399,8 +402,18 @@ export class PanelReadModel { const dates = panelLastSevenDateKeys(this.now(), timeZone); const memories = this.deps.repos.memories.listStats(); const skillMemories = memories.filter((memory) => memory.memoryLayer === "Skill"); + const activeSkillIds = new Set( + skillMemories.filter((memory) => memory.status === "activated").map((memory) => memory.id) + ); const logs = this.deps.repos.runtime.listApiLogs({ limit: 10_000, offset: 0 }).logs .filter((log) => dates.includes(panelDateKey(log.calledAt, timeZone))); + const recentlyUsedSkills = panelActivatedSkillUseCount({ + activeSkillIds, + recallEvents: this.deps.repos.runtime.listRecallEvents({ limit: PANEL_USAGE_EVENT_LIMIT }), + skillTrials: this.deps.repos.runtime.listSkillTrials({ limit: PANEL_USAGE_EVENT_LIMIT }), + dates, + timeZone + }); const recallScores = logs .filter((log) => log.toolName === "memory_search") .map((log) => panelRecallScore(log.outputJson)) @@ -410,8 +423,8 @@ export class PanelReadModel { metrics: { avgRecallScore: panelRoundDecimal(panelAverage(recallScores), 2), recallEvents: logs.filter((log) => log.toolName === "memory_search").length, - activeSkills: skillMemories.filter((memory) => memory.status === "activated").length, - recentlyUsedSkills: skillMemories.filter((memory) => dates.includes(panelDateKey(memory.updatedAt, timeZone))).length, + activeSkills: activeSkillIds.size, + recentlyUsedSkills, avgToolLatencyMs: panelRoundInt(panelAverage(durations)), p95ToolLatencyMs: panelPercentile95(durations) }, @@ -676,6 +689,29 @@ export class PanelReadModel { } +export function panelActivatedSkillUseCount(input: { + activeSkillIds: ReadonlySet; + recallEvents: RecallEventRecord[]; + skillTrials: SkillTrialRecord[]; + dates: string[]; + timeZone: string; +}): number { + if (input.activeSkillIds.size === 0) return 0; + const dates = new Set(input.dates); + const invoked = input.skillTrials.filter((trial) => + input.activeSkillIds.has(trial.skillMemoryId) && + dates.has(panelDateKey(trial.createdAt, input.timeZone)) + ).length; + const recalled = input.recallEvents.reduce((count, event) => { + if (!dates.has(panelDateKey(event.createdAt, input.timeZone))) return count; + const recalledActiveSkills = new Set( + event.hitMemoryIds.filter((memoryId) => input.activeSkillIds.has(memoryId)) + ); + return count + recalledActiveSkills.size; + }, 0); + return invoked + recalled; +} + function panelWorldModelScope(scope: L3WorldModelScopeRecord): WorldModelScope { if (!scope.projectId) return { kind: "general" }; const display = workspaceUriDisplay(scope.workspaceUri); diff --git a/Memory/src/service/read-model/panel.ts b/Memory/src/service/read-model/panel.ts index ad871c928..65afe583e 100644 --- a/Memory/src/service/read-model/panel.ts +++ b/Memory/src/service/read-model/panel.ts @@ -100,6 +100,7 @@ export function panelSourceForMemory(memory: MemoryRow): string { external.source === "computer_history" || typeof provenance.historyId === "string" )) return "computer_history"; return panelSourceForStatsRow({ + id: memory.id, conversationId: memory.conversationId, sessionId: memory.sessionId, agentId: memory.agentId, diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index b33b216f8..2f26b078e 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -844,7 +844,8 @@ export class MemoryRepository { listStats(): MemoryStatsRow[] { const rows = this.db .prepare( - `SELECT conversation_id, + `SELECT id, + conversation_id, session_id, agent_id, app_id, @@ -858,6 +859,7 @@ export class MemoryRepository { WHERE deleted_at IS NULL` ) .all() as Array<{ + id: string; conversation_id: string | null; session_id: string | null; agent_id: string | null; @@ -870,6 +872,7 @@ export class MemoryRepository { internal_source: unknown; }>; return rows.map((row) => ({ + id: row.id, conversationId: row.conversation_id ?? undefined, sessionId: row.session_id ?? undefined, agentId: row.agent_id ?? undefined, @@ -3089,6 +3092,28 @@ export class RuntimeRepository { return event; } + listRecallEvents(input: { + userId?: string; + limit?: number; + } = {}): RecallEventRecord[] { + const clauses = ["1=1"]; + const params: SqlValue[] = []; + if (input.userId) { + clauses.push("user_id = ?"); + params.push(input.userId); + } + const rows = this.db + .prepare( + `SELECT * + FROM recall_events + WHERE ${clauses.join(" AND ")} + ORDER BY created_at DESC + LIMIT ?` + ) + .all(...params, input.limit ?? 10_000) as SqlRecallEventRow[]; + return rows.map(recallEventFromSql); + } + getRecallEvent(id: string): RecallEventRecord | undefined { const row = this.db .prepare(`SELECT * FROM recall_events WHERE id = ?`) diff --git a/Memory/src/types.ts b/Memory/src/types.ts index 7ef5d0a1a..1a65e7e49 100644 --- a/Memory/src/types.ts +++ b/Memory/src/types.ts @@ -171,6 +171,7 @@ export interface MemoryRow { /** Narrow projection used by aggregate read models without loading memory payloads or vectors. */ export interface MemoryStatsRow { + id: string; conversationId?: string; sessionId?: string; agentId?: string; diff --git a/Memory/tests/agent-source-runtime.test.ts b/Memory/tests/agent-source-runtime.test.ts index 02f16f8a7..bac0f67fe 100644 --- a/Memory/tests/agent-source-runtime.test.ts +++ b/Memory/tests/agent-source-runtime.test.ts @@ -1,6 +1,8 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { setImmediate as yieldToIo } from "node:timers/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createAgentSourceExecutor, @@ -408,7 +410,7 @@ describe("standalone Agent source executor", () => { }); it("owns startup and recurring scans in the Memory process", async () => { - vi.useFakeTimers(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); const root = tempRoot(); const configPath = join(root, "config.yaml"); writeFileSync(configPath, [ @@ -467,22 +469,27 @@ describe("standalone Agent source executor", () => { scheduleWorker }); - executor.startAutomation(); - await vi.advanceTimersByTimeAsync(9); - expect(addMemory).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - await waitForFakeTimerScan(executor); - expect(addMemory).toHaveBeenCalledTimes(1); - expect(enqueuePendingImportSummaries).toHaveBeenLastCalledWith(1_000, ["memory-1"]); - expect(scheduleWorker).toHaveBeenCalledTimes(1); + try { + executor.startAutomation(); + await vi.advanceTimersByTimeAsync(9); + expect(addMemory).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(100); - await waitForFakeTimerScan(executor); - expect(addMemory).toHaveBeenCalledTimes(2); - expect(enqueuePendingImportSummaries).toHaveBeenLastCalledWith(1_000, ["memory-2"]); - expect(scheduleWorker).toHaveBeenCalledTimes(2); - executor.dispose(); + await vi.advanceTimersByTimeAsync(1); + await waitForScanIo(executor); + expect(executor.scanStatus()).toMatchObject({ running: false, error: null, progress: { phase: "done" } }); + expect(addMemory).toHaveBeenCalledTimes(1); + expect(enqueuePendingImportSummaries).toHaveBeenLastCalledWith(1_000, ["memory-1"]); + expect(scheduleWorker).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + await waitForScanIo(executor); + expect(executor.scanStatus()).toMatchObject({ running: false, error: null, progress: { phase: "done" } }); + expect(addMemory).toHaveBeenCalledTimes(2); + expect(enqueuePendingImportSummaries).toHaveBeenLastCalledWith(1_000, ["memory-2"]); + expect(scheduleWorker).toHaveBeenCalledTimes(2); + } finally { + await executor.dispose(); + } }); it("emits memory_desktop add analytics for standalone scan writes", async () => { @@ -893,12 +900,13 @@ function fixtureMessage( } as const; } -async function waitForFakeTimerScan(executor: ReturnType): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { - if (!executor.scanStatus().running) return; - await vi.advanceTimersByTimeAsync(1); +async function waitForScanIo(executor: ReturnType): Promise { + const deadline = performance.now() + 2_000; + while (executor.scanStatus().running) { + if (performance.now() >= deadline) throw new Error("automatic scan did not complete before the I/O deadline"); + // Let real filesystem and SQLite work finish without firing the next scan timer. + await yieldToIo(); } - throw new Error("automatic scan did not complete"); } function tempRoot(): string { diff --git a/Memory/tests/build-standalone.test.ts b/Memory/tests/build-standalone.test.ts new file mode 100644 index 000000000..3f59c7b99 --- /dev/null +++ b/Memory/tests/build-standalone.test.ts @@ -0,0 +1,23 @@ +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("standalone runtime packaging", () => { + it("resolves the patched Node 24.22.1 runtime from the installed pkg toolchain", () => { + const memoryRequire = createRequire(import.meta.url); + const pkgRequire = createRequire(memoryRequire.resolve("@yao-pkg/pkg/package.json")); + const { getNodeVersion } = pkgRequire("@yao-pkg/pkg-fetch") as { + getNodeVersion: (range: string) => string; + }; + expect(getNodeVersion("node24.22.1")).toBe("v24.22.1"); + }); + + it("targets the Node 24 pkg runtime and keeps both Linux sqlite prebuilds", async () => { + const source = await readFile(resolve(import.meta.dirname, "../src/cli/scripts/build-standalone.mjs"), "utf8"); + expect(source).toContain('const PKG_NODE_RANGE = "node24.22.1";'); + expect(source).toContain("linux-${arch}.node"); + expect(source).toContain("linuxmusl-${arch}.node"); + expect(source).toContain("better-sqlite3 has no ${name} prebuild"); + }); +}); diff --git a/Memory/tests/runtime-installer.test.ts b/Memory/tests/runtime-installer.test.ts index 8f0f33451..d18338043 100644 --- a/Memory/tests/runtime-installer.test.ts +++ b/Memory/tests/runtime-installer.test.ts @@ -46,33 +46,60 @@ describe("standalone Memory runtime installer", () => { }); }); - it("compares stable and prerelease versions", () => { + it("compares complete version identifiers by character", () => { expect(compareVersions("2.1.0", "2.0.9")).toBe(1); expect(compareVersions("2.1.0", "2.1.0")).toBe(0); - expect(compareVersions("2.1.0-beta.1", "2.1.0")).toBe(-1); + expect(compareVersions("2.1.0-beta.1", "2.1.0")).toBe(1); }); it.each([ - ["2.1.0-rc.2", "2.1.0-rc.10"], + ["1.2.4", "1.2.401"], + ["1.2.401", "1.2.402"], + ["1.2.402", "1.2.5"], + ["1.2.5", "1.2.6"], + ["1.2.6", "1.3.0"], + ["1.2.10", "1.2.9"], + ["2.1.0-rc.10", "2.1.0-rc.2"], ["2.1.0-alpha", "2.1.0-alpha.1"], ["2.1.0-alpha.1", "2.1.0-alpha.beta"], ["2.1.0-beta.11", "2.1.0-rc.1"], - ["2.1.0-rc.10", "2.1.0"], + ["2.1.0", "2.1.0-rc.10"], ["2.1.0-rc.9007199254740992", "2.1.0-rc.9007199254740993"], - ])("orders prerelease %s before %s", (older, newer) => { + ])("orders %s before %s by character", (older, newer) => { expect(compareVersions(older, newer)).toBe(-1); expect(compareVersions(newer, older)).toBe(1); }); - it("ignores build metadata when comparing semantic versions", () => { - expect(compareVersions("2.1.0-rc.1+first", "2.1.0-rc.1+second")).toBe(0); + it("includes build metadata in character comparisons", () => { + expect(compareVersions("2.1.0-rc.1+first", "2.1.0-rc.1+second")).toBe(-1); }); it.each(["2.1.0-rc..1", "2.1.0-rc.01", "02.1.0", "2.1.0-"])( "rejects malformed semantic version %s", - (version) => expect(() => compareVersions(version, "2.1.0")).toThrow("invalid semantic version") + (version) => { + expect(() => compareVersions(version, "2.1.0")).toThrow("invalid semantic version"); + expect(() => compareVersions("2.1.0", version)).toThrow("invalid semantic version"); + } ); + it.each([true, false])("activates a character-newer runtime and prevents reverting it (preferInstalledCompatible=%s)", async (preferInstalledCompatible) => { + const root = tempRoot(); + const home = join(root, "home"); + const flags = { home, skipServiceRegistration: true, skipHealthCheck: true, agents: [] }; + const older = createRuntimeDirectory(root, "1.2.401"); + const newer = createRuntimeDirectory(root, "1.2.5"); + await installMemoryRuntime({ ...flags, runtimeDirectory: older }); + await expect(installMemoryRuntime({ ...flags, preferInstalledCompatible, version: "1.2.5", dryRun: true })) + .resolves.toMatchObject({ pointer: { version: "1.2.5" } }); + await expect(installMemoryRuntime({ ...flags, preferInstalledCompatible, runtimeDirectory: newer })) + .resolves.toMatchObject({ version: "1.2.5" }); + for (const dryRun of [true, false]) { + await expect(installMemoryRuntime({ ...flags, version: "1.2.401", runtimeDirectory: older, dryRun })) + .rejects.toThrow("refusing to downgrade Memory"); + } + expect(await currentInstalledRuntime(home)).toMatchObject({ version: "1.2.5" }); + }); + it("plans a service-only install without downloading or mutating disk", async () => { const home = tempRoot(); const result = await installMemoryRuntime({ home, dryRun: true }); diff --git a/Memory/tests/service/project-environment/local-scanner.test.ts b/Memory/tests/service/project-environment/local-scanner.test.ts index 6736cce8f..238f9b3ac 100644 --- a/Memory/tests/service/project-environment/local-scanner.test.ts +++ b/Memory/tests/service/project-environment/local-scanner.test.ts @@ -56,6 +56,21 @@ describe("local project scanner", () => { expect(result.textFiles).toEqual([]); }); + it("applies nested gitignore rules through the package ignore adapter", async () => { + const { root, uri } = await fixture(); + await mkdir(join(root, "src", "generated"), { recursive: true }); + await writeFile(join(root, ".gitignore"), "src/generated/\n"); + await writeFile(join(root, "src", "index.ts"), "export const value = 1;\n"); + await writeFile(join(root, "src", "generated", "runtime.ts"), "generated\n"); + + const result = await scanLocalProject(uri); + expect(result.entries.map((entry) => entry.relativePath)).toEqual([ + ".gitignore", + "src", + "src/index.ts", + ]); + }); + it("does not decode oversized or invalid UTF-8 deterministic files", async () => { const { root, uri } = await fixture(); await writeFile(join(root, "package.json"), Buffer.alloc(1024 * 1024 + 1, 0x61)); diff --git a/Memory/tests/service/read-model/panel-analysis-count.test.ts b/Memory/tests/service/read-model/panel-analysis-count.test.ts new file mode 100644 index 000000000..fd3185608 --- /dev/null +++ b/Memory/tests/service/read-model/panel-analysis-count.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { RecallEventRecord, SkillTrialRecord } from "../../../src/storage/repositories.js"; +import { panelActivatedSkillUseCount } from "../../../src/service/read-model/panel-read.js"; + +describe("panelActivatedSkillUseCount", () => { + it("counts activated skill recall and invocation events inside the seven-day window", () => { + const activeSkillIds = new Set(["skill_active", "skill_active_unused"]); + const recallEvents: RecallEventRecord[] = [ + recall("recall_recent", ["skill_active", "skill_resolving", "skill_active"], "2026-10-22T09:00:00.000Z"), + recall("recall_old", ["skill_active"], "2026-10-15T09:00:00.000Z") + ]; + const skillTrials: SkillTrialRecord[] = [ + trial("trial_recent_active", "skill_active", "2026-10-21T09:00:00.000Z"), + trial("trial_recent_resolving", "skill_resolving", "2026-10-21T10:00:00.000Z"), + trial("trial_old_active", "skill_active", "2026-10-15T10:00:00.000Z") + ]; + + expect(panelActivatedSkillUseCount({ + activeSkillIds, + recallEvents, + skillTrials, + dates: ["2026-10-17", "2026-10-18", "2026-10-19", "2026-10-20", "2026-10-21", "2026-10-22", "2026-10-23"], + timeZone: "+00:00" + })).toBe(2); + }); +}); + +function recall(id: string, hitMemoryIds: string[], createdAt: string): RecallEventRecord { + return { + id, + userId: "panel-analysis-user", + query: id, + layers: ["Skill"], + hitMemoryIds, + request: {}, + createdAt + }; +} + +function trial(id: string, skillMemoryId: string, createdAt: string): SkillTrialRecord { + return { + id, + userId: "panel-analysis-user", + skillMemoryId, + episodeId: `episode_${id}`, + status: "pending", + outcome: "unknown", + createdAt + }; +} diff --git a/Memory/tests/service/read-model/panel-analysis.test.ts b/Memory/tests/service/read-model/panel-analysis.test.ts new file mode 100644 index 000000000..1f67e3247 --- /dev/null +++ b/Memory/tests/service/read-model/panel-analysis.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Repositories } from "../../../src/storage/repositories.js"; +import { + insertActiveSkillMemoryForTest, + setSkillLifecycleForTest +} from "../../fixtures/evolution-fixture.js"; +import { createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js"; + +const { + cleanup, + createTestService +} = createMemoryServiceFixture(); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-23T12:00:00.000Z")); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("MemoryService / read model / panel analysis", () => { + it("counts recent recalls and invocations of activated skills instead of recent skill updates", () => { + const { db, service } = createTestService(); + const userId = "panel-analysis-user"; + const activeUsedId = "skill_panel_active_used"; + const activeUnusedId = "skill_panel_active_unused"; + const resolvingId = "skill_panel_resolving"; + + for (const id of [activeUsedId, activeUnusedId, resolvingId]) { + insertActiveSkillMemoryForTest(db, { + id, + userId, + sessionId: "panel-analysis-session", + agentId: "codex", + profileId: "default" + }); + } + setSkillLifecycleForTest(db, resolvingId, { + eta: 0, + status: "candidate", + trialsAttempted: 0, + trialsPassed: 0 + }); + + const repos = new Repositories(db.db); + repos.runtime.insertRecallEvent({ + id: "recall_recent", + userId, + query: "use the active skill", + layers: ["Skill"], + hitMemoryIds: [activeUsedId, resolvingId, activeUsedId], + request: {}, + createdAt: "2026-10-22T09:00:00.000Z" + }); + repos.runtime.insertRecallEvent({ + id: "recall_outside_window", + userId, + query: "old active skill recall", + layers: ["Skill"], + hitMemoryIds: [activeUsedId], + request: {}, + createdAt: "2026-10-15T09:00:00.000Z" + }); + repos.runtime.insertSkillTrial(skillTrial("trial_recent_active", activeUsedId, userId, "2026-10-21T09:00:00.000Z")); + repos.runtime.insertSkillTrial(skillTrial("trial_recent_resolving", resolvingId, userId, "2026-10-21T10:00:00.000Z")); + repos.runtime.insertSkillTrial(skillTrial("trial_outside_window", activeUsedId, userId, "2026-10-15T10:00:00.000Z")); + + const analysis = service.panelAnalysis({ timeZone: "+00:00" }); + + expect(analysis.metrics.activeSkills).toBe(2); + expect(analysis.metrics.recentlyUsedSkills).toBe(2); + }); +}); + +function skillTrial(id: string, skillMemoryId: string, userId: string, createdAt: string) { + return { + id, + userId, + skillMemoryId, + episodeId: `episode_${id}`, + status: "pending" as const, + outcome: "unknown" as const, + createdAt + }; +} diff --git a/Migrations/package.json b/Migrations/package.json index efaf76ed7..de1e79f54 100644 --- a/Migrations/package.json +++ b/Migrations/package.json @@ -20,7 +20,7 @@ "test": "vitest run" }, "dependencies": { - "better-sqlite3": "^12.11.1", + "better-sqlite3": "^13.0.3", "proper-lockfile": "^4.1.2", "yaml": "^2.8.2" }, diff --git a/README.md b/README.md index f44e8834e..bc3729b58 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Download Memmy from the [official website](https://memmy.bot/) or [GitHub Releas ![Memmy TUI](docs/assets/tui.png) -On Linux x64 or arm64 with Node.js 22 or newer and an available systemd user session: +On Linux x64 or arm64 with an available systemd user session, use Node.js 22.23.4+ (22.x), 24.22.1+ (24.x), or 26.12.1+ (26.x). Other major versions are not accepted by the installer: ```bash curl -fsSL https://raw.githubusercontent.com/MemTensor/memmy-agent/main/scripts/install.sh | bash @@ -96,6 +96,8 @@ memmy The installer enables the local Memory Service immediately as `memmy-memory.service`. The first bare `memmy` invocation opens the model setup wizard when needed, then enables `memmy-gateway.service`, waits for it to become ready, and enters the TUI. Both are `systemd --user` services bound to localhost and remain available after the TUI or terminal exits. They start again on later logins; the installer does not enable linger. Only the installer launcher activates this service management, so source-built Linux CLIs keep their existing behavior. +During Gateway startup, temporary HTTP 503 responses from the bootstrap endpoint are retried until the Gateway becomes ready or the startup wait times out. + Before starting or reconnecting to the Gateway, `memmy` refreshes a private `~/.memmy/systemd/gateway.env` file (mode `0600`) with configuration-referenced environment variables, common Provider credentials, and the terminal `PATH`. If those values change, the next bare `memmy` invocation restarts the user service with the new environment. ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index ba5e8751d..7f54a3353 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,7 +86,7 @@ #### 2. 使用 memmy CLI /TUI ![tui.png](docs/assets/tui.png) -Linux x64 或 arm64(需 Node.js 22 或更高版本,并且 systemd 用户会话可用)可一行安装: +Linux x64 或 arm64 需有可用的 systemd 用户会话,并使用 Node.js 22.23.4+(22.x)、24.22.1+(24.x)或 26.12.1+(26.x)。安装器不接受其他主版本: ```bash curl -fsSL https://raw.githubusercontent.com/MemTensor/memmy-agent/main/scripts/install.sh | bash @@ -95,6 +95,8 @@ memmy 安装器会立即启用本地 Memory Service(`memmy-memory.service`)。首次裸执行 `memmy` 时,如尚未配置模型,会在当前终端进入配置向导;保存后启用 `memmy-gateway.service`,等待就绪并进入 TUI。二者均为只绑定本机地址的 `systemd --user` 服务,退出 TUI 或关闭终端不会停止;以后登录时会自动启动。安装器不会启用 linger。只有安装器生成的 launcher 会启用这套服务管理,源码构建的 Linux CLI 保持原有行为。 +Gateway 启动期间,bootstrap 接口临时返回 HTTP 503 时会继续重试,直到 Gateway 就绪或启动等待超时。 + 启动或重新连接 Gateway 前,`memmy` 会把配置引用的环境变量、常用 Provider 凭据和终端 `PATH` 刷新到权限为 `0600` 的私有文件 `~/.memmy/systemd/gateway.env`。这些值发生变化后,下次裸执行 `memmy` 会使用新环境重启用户服务。 ```bash diff --git a/changelog.md b/changelog.md new file mode 100644 index 000000000..62161ff9d --- /dev/null +++ b/changelog.md @@ -0,0 +1,23 @@ +# Changelog + +## 1.2.401 - 2026-10-23 + +### Fixed + +- Accept valid macOS update packages during CPU architecture checks. +- Preserve the Developer ID requirement when staging macOS updates and compare update version characters consistently. +- Hide empty User Memories pagination and count seven-day activated-skill recalls and invocations correctly. +- Include `ignore` in packaged Memory. +- Verify SQLite 13 N-API prebuilds in Memory runtime packages. +- Retry temporary Linux Gateway bootstrap HTTP 503 responses. +- Improve Windows installer builds by avoiding redundant native rebuilds, isolating edition outputs, and retrying transient signing and file-lock errors. + +### Changed + +- Route host-managed file access through approvals, including directory traversal, search, edits, and PDF/DOCX text reads; keep runtime state and plugin discovery isolated from unapproved projects. +- Rename the managed Agent integration to product-neutral environment variables, request fields, authentication headers, and health fields. Applications embedding the managed Agent must update their integration together with the Agent. +- Require Node.js 22.23.4+ (22.x), 24.22.1+ (24.x), or 26.12.1+ (26.x) for the Linux CLI installer. + +- Upgrade Electron to `43.8.1` and `better-sqlite3` to `^13.0.3`; align native runtime packaging with the bundled N-API binaries. +- Pin the standalone Memory executable to Node.js 24.22.1 with `@yao-pkg/pkg` 6.24.0, and retain both glibc and musl SQLite variants for Linux targets. +- Synchronize Memmy Desktop and Agent to `1.2.401`; Memory remains independently versioned at `2.1.7`. diff --git a/docs/cn/memory/overview.mdx b/docs/cn/memory/overview.mdx index 821010261..0a625246e 100644 --- a/docs/cn/memory/overview.mdx +++ b/docs/cn/memory/overview.mdx @@ -309,4 +309,8 @@ memmy-memory search "你的查询" --verbose | 洞察 | 分析 / 日志 | 查看写入与演化分析图表,以及 Memory API 的搜索和写入日志 | | 系统 | 跨Agent接入 | 管理 Agent 来源、历史扫描以及 Hook/插件接入状态 | +分析页的“近 7 天使用技能…次”统计当前已激活技能的召回命中与调用记录。同一次召回中的相同技能只计一次,不同召回或调用分别计数。这是使用总次数,不是不同技能的数量;仅编辑技能不会增加使用次数。 + +用户记忆列表有条目时才显示分页控件。 + 日志页对 `memory.search` 和 `memory.add` 提供专门详情(搜索候选、写入字段、结果状态),可用来解释某条记忆为何被命中或写入。 diff --git a/docs/cn/reference/memory-api.mdx b/docs/cn/reference/memory-api.mdx index cbcae8abb..9a30bb408 100644 --- a/docs/cn/reference/memory-api.mdx +++ b/docs/cn/reference/memory-api.mdx @@ -20,6 +20,12 @@ icon: Database | `GET` | `/api/v1/panel/analysis` | 记忆分析 | | `GET` | `/api/v1/panel/items` | 条目列表 | +## 分析指标 + +`GET /api/v1/panel/analysis` 返回 `metrics` 对象。其中 `metrics.recentlyUsedSkills` 统计当前状态为 `activated` 的技能的召回命中与调用记录。统计窗口按请求解析出的时区覆盖当天及之前六个日历日期,以各事件的创建时间判断是否计入。 + +同一次召回中,同一个技能 ID 即使出现多次也只计一次;不同召回和调用分别计数。该值是使用总次数,不是不同技能或最近编辑过的技能数量;仅编辑技能不会增加该值。 + ## 本地存储配置 ```yaml diff --git a/docs/cn/start/getting-started.mdx b/docs/cn/start/getting-started.mdx index bf3bf3cc4..1ff6b6794 100644 --- a/docs/cn/start/getting-started.mdx +++ b/docs/cn/start/getting-started.mdx @@ -16,7 +16,7 @@ icon: Rocket ## 快速设置 -Linux x64 或 arm64 的纯终端用户需先安装 Node.js 22 或更高版本,并确认 systemd 用户会话可用,然后执行: +Linux x64 或 arm64 的纯终端用户需先安装 Node.js 22.23.4+(22.x)、24.22.1+(24.x)或 26.12.1+(26.x),并确认 systemd 用户会话可用。安装器不接受其他主版本,然后执行: ```bash curl -fsSL https://raw.githubusercontent.com/MemTensor/memmy-agent/main/scripts/install.sh | bash diff --git a/docs/en/memory/overview.mdx b/docs/en/memory/overview.mdx index 0d53c5815..1f430b576 100644 --- a/docs/en/memory/overview.mdx +++ b/docs/en/memory/overview.mdx @@ -309,4 +309,8 @@ The `/memory` sidebar is organized into three groups by purpose: | Insights | Analytics / Logs | Review write and evolution analytics, plus Memory API search and write logs | | System | Cross-Agent access | Manage Agent sources, history scanning, and Hook/plugin connection status | +The Analytics page's “skill uses in the last 7 days” counts recall hits and invocation records for currently activated skills. The same skill counts once per recall event; separate recalls or invocations count again. This is a usage total, not a count of distinct skills, and editing a skill alone does not increase it. + +The User Memories list shows pagination controls only when it contains items. + The Logs page provides dedicated detail views for `memory.search` and `memory.add` (search candidates, written fields, result status), useful for explaining why a memory was matched or written. diff --git a/docs/en/reference/memory-api.mdx b/docs/en/reference/memory-api.mdx index 9d6320696..a5ac0bb29 100644 --- a/docs/en/reference/memory-api.mdx +++ b/docs/en/reference/memory-api.mdx @@ -20,6 +20,12 @@ Default address `http://127.0.0.1:18960`. Once `storage.token`, `MEMMY_MEMORY_TO | `GET` | `/api/v1/panel/analysis` | Memory analytics | | `GET` | `/api/v1/panel/items` | Entry list | +## Analysis Metrics + +`GET /api/v1/panel/analysis` returns a `metrics` object. `metrics.recentlyUsedSkills` counts recall hits and invocation records for skills whose current status is `activated`. The window covers today and the preceding six calendar dates in the resolved request time zone, using each event's creation time. + +Each skill counts once within a recall event, even if its ID appears more than once. Separate recalls and invocations count again. The value is a usage total, not a count of distinct skills or recently edited skill records; editing a skill alone does not increase it. + ## Local Storage Configuration ```yaml diff --git a/docs/en/start/getting-started.mdx b/docs/en/start/getting-started.mdx index 9f3d83b1a..b68ca19ca 100644 --- a/docs/en/start/getting-started.mdx +++ b/docs/en/start/getting-started.mdx @@ -16,7 +16,7 @@ icon: Rocket ## Quick Setup -For a terminal-only installation on Linux x64 or arm64, install Node.js 22 or newer and make sure a systemd user session is available, then run: +For a terminal-only installation on Linux x64 or arm64, use Node.js 22.23.4+ within 22.x, 24.22.1+ within 24.x, or 26.12.1+ within 26.x, and make sure a systemd user session is available. The installer rejects other major versions. Then run: ```bash curl -fsSL https://raw.githubusercontent.com/MemTensor/memmy-agent/main/scripts/install.sh | bash diff --git a/package-lock.json b/package-lock.json index 53f10a478..19b1e5e34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "memmy-agent", - "version": "1.2.4", + "version": "1.2.401", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memmy-agent", - "version": "1.2.4", + "version": "1.2.401", "workspaces": [ "Migrations", "AgentSourceCore", @@ -590,7 +590,7 @@ }, "App/shell/desktop": { "name": "@memmy/desktop", - "version": "1.2.4", + "version": "1.2.401", "dependencies": { "@memmy/backend": "0.0.0", "@memmy/desktop-interface": "0.0.0", @@ -601,7 +601,7 @@ "yaml": "^2.9.0" }, "devDependencies": { - "electron": "38.4.0", + "electron": "43.8.1", "electron-builder": "^26.0.12" } }, @@ -612,119 +612,6 @@ "@memmy/local-api-contracts": "0.0.0" } }, - "App/shell/desktop/node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "App/shell/desktop/node_modules/@types/node": { - "version": "22.19.20", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz", - "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "App/shell/desktop/node_modules/electron": { - "version": "38.4.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-38.4.0.tgz", - "integrity": "sha512-9CsXKbGf2qpofVe2pQYSgom2E//zLDJO2rGLLbxgy9tkdTOs7000Gte+d/PUtzLjI/DS95jDK0ojYAeqjLvpYg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", - "extract-zip": "^2.0.1" - }, - "bin": { - "electron": "cli.js" - }, - "engines": { - "node": ">= 12.20.55" - } - }, - "App/shell/desktop/node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "App/shell/desktop/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "App/shell/desktop/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "App/shell/desktop/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "App/shell/desktop/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "App/shell/desktop/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "Knowledge": { "name": "@memmy/knowledge", "version": "0.0.0", @@ -750,7 +637,7 @@ "dependencies": { "@huggingface/transformers": "^3.8.0", "@memmy/agent-source-core": "0.0.0", - "better-sqlite3": "^12.6.3", + "better-sqlite3": "^13.0.3", "dotenv": "^16.6.1", "fast-xml-parser": "^5.8.0", "fzstd": "^0.1.1", @@ -771,7 +658,7 @@ "@preact/signals": "^2.8.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.9.1", - "@yao-pkg/pkg": "^6.22.0", + "@yao-pkg/pkg": "6.24.0", "esbuild": "^0.27.4", "preact": "^10.27.2", "tsx": "^4.22.3", @@ -1279,7 +1166,7 @@ "name": "@memmy/migrations", "version": "0.0.0", "dependencies": { - "better-sqlite3": "^12.11.1", + "better-sqlite3": "^13.0.3", "proper-lockfile": "^4.1.2", "yaml": "^2.8.2" }, @@ -1685,6 +1572,16 @@ "node": ">=18" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.6.tgz", + "integrity": "sha512-/aQLgFDdCSnwChfUwqekSmE1q2dCTzhUy5saIHBb5vyYn5LzWaKPaIz1wM0lDMCZYBoIG4svJNvAJb6vkI8BuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -1744,6 +1641,38 @@ "node": ">=10" } }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@electron/notarize": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", @@ -4403,17 +4332,6 @@ "@types/node": "*" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", @@ -5237,9 +5155,9 @@ } }, "node_modules/@yao-pkg/pkg": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.22.0.tgz", - "integrity": "sha512-u+ZgwLsvEFB+Q1rA+IGymgxnm+anl1qGJWsTH6hDHyy2cCiOvMteDNK7NgqFYxN/zdithtORIlCsYSyhAXAgQA==", + "version": "6.24.0", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.24.0.tgz", + "integrity": "sha512-SMwFLic12noHLbqxhhQVClVX6YZlQtzwwez6dq7Xj64rXX6OFgZTTQbPgw4wgec5IoUN+6rCRKgyW/cnVPOwkA==", "dev": true, "license": "MIT", "dependencies": { @@ -5248,7 +5166,7 @@ "@babel/traverse": "^7.23.0", "@babel/types": "^7.23.0", "@roberts_lando/vfs": "^0.3.3", - "@yao-pkg/pkg-fetch": "3.6.5", + "@yao-pkg/pkg-fetch": "3.6.7", "esbuild": "^0.28.1", "into-stream": "^9.1.0", "multistream": "^4.1.0", @@ -5271,9 +5189,9 @@ } }, "node_modules/@yao-pkg/pkg-fetch": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.6.5.tgz", - "integrity": "sha512-Sd1Hff7imsF2rcZ2GLQuIzVm2fc3Za+nE4KSWPTwIYegN/r90UFg3bq0MLSugbWQaVht72zzxV0MiKE0wvT1zA==", + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.6.7.tgz", + "integrity": "sha512-uEeU5druVgHffruTAXzW6nf0wHfREWlCq5LBtQ4hCdLBDbwTteuym5NrxREur3EgNcqFRenEi6Rivwo723PkHg==", "dev": true, "license": "MIT", "dependencies": { @@ -5316,22 +5234,22 @@ } }, "node_modules/@yao-pkg/pkg-fetch/node_modules/tar-stream": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", - "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz", + "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==", "dev": true, "license": "MIT", "dependencies": { - "b4a": "^1.6.4", - "bare-fs": "^4.5.5", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" + "b4a": "^1.9.0", + "bare-fs": "^4.8.2", + "fast-fifo": "^1.3.2", + "streamx": "^2.28.1" } }, "node_modules/@yao-pkg/pkg-fetch/node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { @@ -6067,9 +5985,9 @@ } }, "node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -6124,9 +6042,9 @@ } }, "node_modules/bare-fs": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", - "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz", + "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6184,9 +6102,9 @@ } }, "node_modules/bare-url": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", - "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.5.tgz", + "integrity": "sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6197,6 +6115,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -6227,32 +6146,22 @@ } }, "node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", - "hasInstallScript": true, + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "node-addon-api": "^8.0.0" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" + "node": ">=22" } }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -6367,6 +6276,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, "funding": [ { "type": "github", @@ -6387,16 +6297,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -6702,6 +6602,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, "license": "ISC" }, "node_modules/chromium-pickle-js": { @@ -7192,6 +7093,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -7207,6 +7109,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, "license": "MIT", "engines": { "node": ">=4.0.0" @@ -7551,6 +7454,25 @@ "node": ">=0.10.0" } }, + "node_modules/electron": { + "version": "43.8.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.8.1.tgz", + "integrity": "sha512-Qa6/xXODEn60oIkyuV8jXmyX84z6nicuBh7BOkPHVZlTTCG1MeTYjAkmxX+1B/kEkErPjs3WCP+JdtwkRaKRcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, "node_modules/electron-builder": { "version": "26.15.2", "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.2.tgz", @@ -7684,6 +7606,16 @@ "node": ">= 4.0.0" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -7704,6 +7636,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -7722,6 +7655,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -8364,6 +8310,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, "license": "(MIT OR WTFPL)", "engines": { "node": ">=6" @@ -8487,27 +8434,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -8684,16 +8610,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -8724,12 +8640,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -8926,6 +8836,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, "license": "MIT" }, "node_modules/fs-extra": { @@ -9128,6 +9039,7 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, "license": "MIT" }, "node_modules/glob": { @@ -9702,6 +9614,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, "funding": [ { "type": "github", @@ -9777,6 +9690,7 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -12028,6 +11942,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12053,6 +11968,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12097,6 +12013,7 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, "license": "MIT" }, "node_modules/monaco-editor": { @@ -12162,6 +12079,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, "license": "MIT" }, "node_modules/napi-postinstall": { @@ -12200,6 +12118,7 @@ "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -12208,6 +12127,15 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -12814,13 +12742,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13025,6 +12946,7 @@ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", @@ -13204,6 +13126,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -13303,6 +13226,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", @@ -13318,6 +13242,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13408,6 +13333,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -13863,6 +13789,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -14334,6 +14261,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, "funding": [ { "type": "github", @@ -14354,6 +14282,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, "funding": [ { "type": "github", @@ -14675,6 +14604,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -14899,6 +14829,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -14911,6 +14842,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -15208,6 +15140,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -15709,6 +15642,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/vary": { @@ -16274,17 +16208,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 78a1fc37a..1705443c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "memmy-agent", - "version": "1.2.4", + "version": "1.2.401", "private": true, "type": "module", "description": "Local-first agent memory substrate with desktop and CLI surfaces.", diff --git a/scripts/install.sh b/scripts/install.sh index f49a20ddd..a9e59dd35 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -47,11 +47,19 @@ if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1 fail "required SHA-256 tool not found (install sha256sum or shasum)" fi -NODE_MAJOR="$(node -p 'Number(process.versions.node.split(".")[0])')" \ - || fail "could not determine Node.js version" -if [ "$NODE_MAJOR" -lt 22 ]; then - fail "Node.js 22 or newer is required (found $(node --version))" -fi +# The CLI runs on the host's Node, so the host decides whether the compromised eMudhra +# roots are still trusted. Require a release that carries the fixed root store. +node -e ' +const fixed = { 22: [22, 23, 4], 24: [24, 22, 1], 26: [26, 12, 1] }; +const current = process.versions.node.split(".").map(Number); +const minimum = fixed[current[0]]; +// Unknown major lines must be explicitly verified before they are accepted. +if (!minimum) process.exit(1); +for (let i = 0; i < 3; i += 1) { + if (current[i] !== minimum[i]) process.exit(current[i] > minimum[i] ? 0 : 1); +} +process.exit(0); +' || fail "Node.js 22.23.4, 24.22.1 or 26.12.1 or newer is required (found $(node --version)); earlier releases still trust the compromised eMudhra roots" NODE_BIN="$(command -v node)" case "$NODE_BIN" in /*) ;; diff --git a/scripts/internal/mac/build-dmg.sh b/scripts/internal/mac/build-dmg.sh index 819a5e76c..cc50135aa 100755 --- a/scripts/internal/mac/build-dmg.sh +++ b/scripts/internal/mac/build-dmg.sh @@ -525,20 +525,12 @@ NODE prune_better_sqlite3_build_artifacts() { local module_dir="$1" - local native_file="$module_dir/build/Release/better_sqlite3.node" + local target_cpu="$2" - if [ ! -f "$native_file" ]; then - return + rm -rf "$module_dir/build" "$module_dir/deps" "$module_dir/src" + if [ -d "$module_dir/prebuilds" ]; then + find "$module_dir/prebuilds" -mindepth 1 -type f ! -name "darwin-$target_cpu.node" -delete fi - - local tmp_dir - tmp_dir="$(mktemp -d)" - mkdir -p "$tmp_dir/build/Release" - cp "$native_file" "$tmp_dir/build/Release/better_sqlite3.node" - rm -rf "$module_dir/build" "$module_dir/deps" - mkdir -p "$module_dir/build/Release" - cp "$tmp_dir/build/Release/better_sqlite3.node" "$native_file" - rm -rf "$tmp_dir" } prune_onnxruntime_native_artifacts() { @@ -696,7 +688,7 @@ verify_desktop_native_deployment_target() { verify_mac_memory_native_artifacts() { local target_cpu="$1" - require_packaged_runtime_file "$RUNTIME_DIR/memory/node_modules/better-sqlite3/build/Release/better_sqlite3.node" + require_packaged_runtime_file "$RUNTIME_DIR/memory/node_modules/better-sqlite3/prebuilds/darwin-$target_cpu.node" require_packaged_runtime_glob "$RUNTIME_DIR/memory/node_modules/sqlite-vec-darwin-$target_cpu/vec0.*" require_packaged_runtime_file "$RUNTIME_DIR/memory/node_modules/onnxruntime-node/bin/napi-v3/darwin/$target_cpu/onnxruntime_binding.node" require_packaged_runtime_glob "$RUNTIME_DIR/memory/node_modules/onnxruntime-node/bin/napi-v3/darwin/$target_cpu/libonnxruntime*.dylib" @@ -851,7 +843,7 @@ PY echo "Packaged offline Memory agent source core must not be a symbolic link." >&2 exit 1 fi - require_packaged_runtime_file "$packaged_memory_runtime/node_modules/better-sqlite3/build/Release/better_sqlite3.node" + require_packaged_runtime_file "$packaged_memory_runtime/node_modules/better-sqlite3/prebuilds/darwin-$target_cpu.node" require_packaged_runtime_glob "$packaged_memory_runtime/node_modules/sqlite-vec-darwin-$target_cpu/vec0.*" require_packaged_runtime_file "$packaged_memory_runtime/node_modules/onnxruntime-node/bin/napi-v3/darwin/$target_cpu/onnxruntime_binding.node" require_packaged_runtime_glob "$packaged_memory_runtime/node_modules/onnxruntime-node/bin/napi-v3/darwin/$target_cpu/libonnxruntime*.dylib" @@ -928,7 +920,7 @@ prune_mac_runtime_artifacts() { rm -f "$RUNTIME_DIR/memmy-agent/dist/skills/README.md" while IFS= read -r module_dir; do - prune_better_sqlite3_build_artifacts "$module_dir" + prune_better_sqlite3_build_artifacts "$module_dir" "$TARGET_CPU" done < <(find "$RUNTIME_DIR" -path "*/node_modules/better-sqlite3" -type d) while IFS= read -r napi_dir; do @@ -1049,14 +1041,6 @@ package_step_start "Resolve Memory runtime lockfile" npm install --prefix "$RUNTIME_DIR/memory" --package-lock-only --ignore-scripts --install-links --os=darwin --cpu="$TARGET_CPU" package_step_start "Install Memory runtime production dependencies" npm ci --prefix "$RUNTIME_DIR/memory" --omit=dev --install-links --os=darwin --cpu="$TARGET_CPU" -package_step_start "Rebuild Memory native modules for Electron" -ELECTRON_VERSION="$(node -p "require('./App/shell/desktop/node_modules/electron/package.json').version")" -node_modules/.bin/electron-rebuild \ - -f \ - -v "$ELECTRON_VERSION" \ - -a "$TARGET_CPU" \ - -w better-sqlite3 \ - -m "$RUNTIME_DIR/memory" package_step_start "Install memmy-agent runtime production dependencies" cp "$AGENT_DIR/package.json" "$RUNTIME_DIR/memmy-agent/package.json" cp "$AGENT_DIR/package-lock.json" "$RUNTIME_DIR/memmy-agent/package-lock.json" @@ -1147,13 +1131,6 @@ package_step_start "Verify memmy-agent runtime exports" if (!fs.readFileSync(commandEntrypoint, "utf8").includes("browser-prepare")) throw new Error("browser-prepare command is missing"); ' ) -package_step_start "Rebuild memmy-agent native modules for Electron" -node_modules/.bin/electron-rebuild \ - -f \ - -v "$ELECTRON_VERSION" \ - -a "$TARGET_CPU" \ - -w better-sqlite3 \ - -m "$RUNTIME_DIR/memmy-agent" package_step_start "Create CLI launchers" create_cli_launcher "$CLI_BIN_DIR/memmy-memory" "dist/src/cli/index.js" resource create_cli_launcher "$CLI_BIN_DIR/memmy" "dist/runtime/memmy-agent/dist/main.js" diff --git a/scripts/internal/win/build-nsis.sh b/scripts/internal/win/build-nsis.sh index 43156cc56..6e540a013 100755 --- a/scripts/internal/win/build-nsis.sh +++ b/scripts/internal/win/build-nsis.sh @@ -76,8 +76,15 @@ npm_with_configured_script_shell() { resolve_electron_dist() { local electron_dist="${MEMMY_ELECTRON_DIST:-}" - if [ -z "$electron_dist" ] && [ -f "$DESKTOP_DIR/node_modules/electron/dist/electron.exe" ]; then - electron_dist="$DESKTOP_DIR/node_modules/electron/dist" + if [ -z "$electron_dist" ]; then + for candidate in \ + "$DESKTOP_DIR/node_modules/electron/dist" \ + "$ROOT_DIR/node_modules/electron/dist"; do + if [ -f "$candidate/electron.exe" ]; then + electron_dist="$candidate" + break + fi + done fi if [ -n "$electron_dist" ]; then @@ -85,6 +92,28 @@ resolve_electron_dist() { fi } +restore_electron_dist() { + if [ -f "$DESKTOP_DIR/node_modules/electron/dist/electron.exe" ] || \ + [ -f "$ROOT_DIR/node_modules/electron/dist/electron.exe" ]; then + return + fi + + local install_script="" + for candidate in \ + "$ROOT_DIR/node_modules/electron/install.js" \ + "$DESKTOP_DIR/node_modules/electron/install.js"; do + if [ -f "$candidate" ]; then + install_script="$candidate" + break + fi + done + if [ -z "$install_script" ]; then + echo "Electron install.js is missing; cannot materialize the Windows Electron runtime." >&2 + exit 1 + fi + node "$install_script" +} + if [ -n "${MEMMY_DESKTOP_VERSION:-}" ]; then DESKTOP_VERSION="$MEMMY_DESKTOP_VERSION" else @@ -130,9 +159,13 @@ case "${MEMMY_ACCOUNT_CHANNEL:-phone}" in ;; esac -FINAL_EXE="$DESKTOP_DIR/release/Memmy-$DESKTOP_VERSION-win32-$PACKAGE_ARCH-$PACKAGE_EDITION-$PACKAGE_SIGNING.exe" +RELEASE_DIR="$DESKTOP_DIR/release" +BUILD_OUTPUT_DIR="$(dirname "$ROOT_DIR")/.memmy-win-build-$PACKAGE_EDITION-$PACKAGE_SIGNING-$$" +UNPACKED_DIR="$BUILD_OUTPUT_DIR/win-unpacked" +FINAL_EXE="$RELEASE_DIR/Memmy-$DESKTOP_VERSION-win32-$PACKAGE_ARCH-$PACKAGE_EDITION-$PACKAGE_SIGNING.exe" +BUILDER_FINAL_EXE="$BUILD_OUTPUT_DIR/Memmy-$DESKTOP_VERSION-win32-$PACKAGE_ARCH-$PACKAGE_EDITION-$PACKAGE_SIGNING.exe" ARTIFACT_NAME="Memmy-$DESKTOP_VERSION-win32-$PACKAGE_ARCH-$PACKAGE_EDITION-$PACKAGE_SIGNING.\${ext}" -package_log_init "win-$DESKTOP_VERSION-$PACKAGE_ARCH-$PACKAGE_EDITION-$PACKAGE_SIGNING" "$DESKTOP_DIR/release/logs" +package_log_init "win-$DESKTOP_VERSION-$PACKAGE_ARCH-$PACKAGE_EDITION-$PACKAGE_SIGNING" "$RELEASE_DIR/logs" package_install_error_trap package_log "Windows package context: version=$DESKTOP_VERSION arch=$PACKAGE_ARCH edition=$PACKAGE_EDITION signing=$PACKAGE_SIGNING installer=$FINAL_EXE" @@ -252,6 +285,54 @@ writeFileSync(templatePath, source.replace(original, replacement)); NODE } +patch_electron_builder_nsis_uninstaller_extraction() { + local target_path="$ROOT_DIR/node_modules/app-builder-lib/out/targets/nsis/NsisTarget.js" + local windows_target_path + windows_target_path="$(to_node_readable_path "$target_path")" + + node - "$windows_target_path" <<'NODE' +const { readFileSync, writeFileSync } = require("node:fs"); + +const [targetPath] = process.argv.slice(2); +const source = readFileSync(targetPath, "utf8"); +const eol = source.includes("\r\n") ? "\r\n" : "\n"; +const marker = "Memmy: extract the NSIS uninstaller before executing the unsigned installer"; + +if (source.includes(marker)) { + process.exit(0); +} + +const original = [ + " else {", + " const wineVm = new WineVm_1.WineVmManager((_a = packager.config.toolsets) === null || _a === void 0 ? void 0 : _a.wine);", + " await wineVm.exec(installerPath, [], { env: { __COMPAT_LAYER: \"RunAsInvoker\" } });", + " }", +].join(eol); +const replacement = [ + " else if (isWin) {", + ` // ${marker}.`, + " try {", + " await nsisUtil_1.UninstallerReader.exec(installerPath, uninstallerPath);", + " } catch (error) {", + " builder_util_1.log.warn(`Unable to extract NSIS uninstaller without execution: ${error.message}`);", + " const wineVm = new WineVm_1.WineVmManager((_a = packager.config.toolsets) === null || _a === void 0 ? void 0 : _a.wine);", + " await wineVm.exec(installerPath, [], { env: { __COMPAT_LAYER: \"RunAsInvoker\" } });", + " }", + " }", + " else {", + " const wineVm = new WineVm_1.WineVmManager((_a = packager.config.toolsets) === null || _a === void 0 ? void 0 : _a.wine);", + " await wineVm.exec(installerPath, [], { env: { __COMPAT_LAYER: \"RunAsInvoker\" } });", + " }", +].join(eol); + +if (!source.includes(original)) { + throw new Error(`Unable to find electron-builder NSIS uninstaller execution block in ${targetPath}`); +} + +writeFileSync(targetPath, source.replace(original, replacement)); +NODE +} + download_url_to_file() { local url="$1" local output_path="$2" @@ -277,38 +358,6 @@ download_url_to_file() { return 1 } -install_better_sqlite3_prebuild_with_download_fallback() { - local electron_version="$1" - local install_output - local install_status - local prebuild_url - local prebuild_file - - log "Trying direct better-sqlite3 prebuild download fallback" - set +e - install_output="$(../.bin/prebuild-install --platform win32 --arch x64 --runtime electron --target "$electron_version" --verbose 2>&1)" - install_status=$? - set -e - printf '%s\n' "$install_output" - - if [ "$install_status" -eq 0 ]; then - return - fi - - prebuild_url="$(printf '%s\n' "$install_output" | sed -nE 's/.*(https:\/\/[^[:space:]]+\.tar\.gz).*/\1/p' | tail -n 1)" - if [ -z "$prebuild_url" ]; then - echo "Unable to locate better-sqlite3 prebuild URL in prebuild-install output." >&2 - return "$install_status" - fi - - mkdir -p prebuilds - prebuild_file="prebuilds/$(basename "$prebuild_url")" - log "Downloading better-sqlite3 prebuild with fallback downloader: $prebuild_url" - download_url_to_file "$prebuild_url" "$prebuild_file" - - ../.bin/prebuild-install --platform win32 --arch x64 --runtime electron --target "$electron_version" -} - create_memory_runtime_manifest() { node "$ROOT_DIR/scripts/internal/win/create-memory-runtime-manifest.mjs" \ "$MEMORY_DIR/package.json" \ @@ -431,6 +480,95 @@ EOF exit 1 } +resolve_windows_signtool() { + local local_app_data="${LOCALAPPDATA:-}" + if [ -z "$local_app_data" ]; then + return 1 + fi + if command -v cygpath >/dev/null 2>&1; then + local_app_data="$(cygpath -u "$local_app_data")" + fi + find "$local_app_data/electron-builder/Cache" \ + -type f \ + -path '*/windows-10/x64/signtool.exe' \ + -print -quit 2>/dev/null +} + +sign_builder_final_installer() { + local signtool_path + local csc_link="${WIN_CSC_LINK:-${CSC_LINK:-}}" + local csc_password="${WIN_CSC_KEY_PASSWORD:-${CSC_KEY_PASSWORD:-}}" + local csc_sha1="${WIN_CSC_SHA1:-${CSC_SHA1:-}}" + local csc_subject="${WIN_CSC_SUBJECT_NAME:-${CSC_SUBJECT_NAME:-}}" + local timestamp_server="${WIN_CSC_TIMESTAMP_SERVER:-${CSC_TIMESTAMP_SERVER:-http://timestamp.digicert.com}}" + + signtool_path="$(resolve_windows_signtool || true)" + if [ -z "$signtool_path" ] || [ ! -f "$BUILDER_FINAL_EXE" ]; then + return 1 + fi + + if [ -n "$csc_link" ] && [ -n "$csc_password" ]; then + "$signtool_path" sign \ + /tr "$timestamp_server" \ + /f "$csc_link" \ + /p "$csc_password" \ + /fd sha256 \ + /td sha256 \ + /d Memmy \ + "$BUILDER_FINAL_EXE" + return + fi + + if [ -n "$csc_sha1" ]; then + "$signtool_path" sign \ + /tr "$timestamp_server" \ + /sha1 "$csc_sha1" \ + /s My \ + /fd sha256 \ + /td sha256 \ + /d Memmy \ + "$BUILDER_FINAL_EXE" + return + fi + + if [ -n "$csc_subject" ]; then + "$signtool_path" sign \ + /tr "$timestamp_server" \ + /n "$csc_subject" \ + /s My \ + /fd sha256 \ + /td sha256 \ + /d Memmy \ + "$BUILDER_FINAL_EXE" + return + fi + + return 1 +} + +run_electron_builder_with_signing_recovery() { + local attempt_log="$BUILD_OUTPUT_DIR/.electron-builder-attempt.log" + local builder_exit_code + + if npx electron-builder "${BUILDER_ARGS[@]}" --win nsis --x64 "$@" --config.artifactName="$ARTIFACT_NAME" 2>&1 | tee "$attempt_log"; then + return 0 + fi + builder_exit_code="${PIPESTATUS[0]}" + + if [ "$PACKAGE_SIGNING" != "signed" ] || + [ ! -f "$BUILDER_FINAL_EXE" ] || + ! grep -Eiq '0x80090020|SignerSign\(\) failed|SignTool Error:.*unexpected internal|No certificates were found' "$attempt_log"; then + return "$builder_exit_code" + fi + + log "electron-builder 在最终安装器签名阶段返回临时错误,准备直接重试最终安装器签名。" + if run_with_retries 3 sign_builder_final_installer; then + log "最终安装器已通过 signtool 重试签名,继续执行安装包验证。" + return 0 + fi + return "$builder_exit_code" +} + verify_windows_x64_native_module() { local native_file="$1" local description="$2" @@ -480,7 +618,9 @@ verify_windows_computer_use_surface() { verify_windows_better_sqlite3_runtime() { local runtime_dir="$1" - local electron_executable="$DESKTOP_DIR/node_modules/electron/dist/electron.exe" + local electron_dist + electron_dist="$(resolve_electron_dist)" + local electron_executable="$electron_dist/electron.exe" local node_runtime_dir node_runtime_dir="$(to_node_readable_path "$runtime_dir")" @@ -523,7 +663,7 @@ verify_packaged_file_matches_runtime() { verify_windows_native_module() { verify_windows_x64_native_module \ - "$RUNTIME_DIR/memory/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "$RUNTIME_DIR/memory/node_modules/better-sqlite3/prebuilds/win32-x64.node" \ "Memory better-sqlite3" } @@ -596,7 +736,7 @@ verify_windows_agent_native_artifacts() { exit 1 fi verify_windows_x64_native_module \ - "$RUNTIME_DIR/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "$RUNTIME_DIR/memmy-agent/node_modules/better-sqlite3/prebuilds/win32-x64.node" \ "memmy-agent better-sqlite3" require_packaged_runtime_file "$node_pty_dir/conpty.node" require_packaged_runtime_file "$node_pty_dir/conpty/conpty.dll" @@ -645,24 +785,24 @@ verify_pruned_windows_runtime() { } verify_packaged_windows_unpacked_artifacts() { - local unpacked_runtime="$DESKTOP_DIR/release/win-unpacked/resources/app.asar.unpacked/dist/runtime" - local packaged_memory_runtime="$DESKTOP_DIR/release/win-unpacked/resources/memory-runtime" + local unpacked_runtime="$UNPACKED_DIR/resources/app.asar.unpacked/dist/runtime" + local packaged_memory_runtime="$UNPACKED_DIR/resources/memory-runtime" local packaged_agent_source_core="$packaged_memory_runtime/node_modules/@memmy/agent-source-core" - local packaged_embedding_model="$DESKTOP_DIR/release/win-unpacked/resources/embedding-models/$EMBEDDING_MODEL_ID" + local packaged_embedding_model="$UNPACKED_DIR/resources/embedding-models/$EMBEDDING_MODEL_ID" - require_packaged_runtime_file "$DESKTOP_DIR/release/win-unpacked/resources/app.asar" + require_packaged_runtime_file "$UNPACKED_DIR/resources/app.asar" node "$ROOT_DIR/scripts/internal/shared/verify-browser-extension.mjs" \ "$(to_node_readable_path "$DESKTOP_DIR/extensions/memmy-browser")" \ - "$(to_node_readable_path "$DESKTOP_DIR/release/win-unpacked/resources/browser-extension")" - require_packaged_runtime_file "$DESKTOP_DIR/release/win-unpacked/resources/cli/memmy-memory.cmd" - require_packaged_runtime_file "$DESKTOP_DIR/release/win-unpacked/resources/cli/memmy.cmd" + "$(to_node_readable_path "$UNPACKED_DIR/resources/browser-extension")" + require_packaged_runtime_file "$UNPACKED_DIR/resources/cli/memmy-memory.cmd" + require_packaged_runtime_file "$UNPACKED_DIR/resources/cli/memmy.cmd" require_packaged_runtime_file "$packaged_memory_runtime/dist/src/cli/index.js" if ! grep -Fq 'set "ENTRY=%RESOURCES_DIR%\memory-runtime\dist\src\cli\index.js"' \ - "$DESKTOP_DIR/release/win-unpacked/resources/cli/memmy-memory.cmd"; then + "$UNPACKED_DIR/resources/cli/memmy-memory.cmd"; then echo "Packaged Memory CLI launcher points outside the external Memory runtime." >&2 exit 1 fi - verify_packaged_runtime_config_boundary "$DESKTOP_DIR/release/win-unpacked/resources" + verify_packaged_runtime_config_boundary "$UNPACKED_DIR/resources" require_packaged_runtime_file "$packaged_agent_source_core/package.json" require_packaged_runtime_file "$packaged_agent_source_core/dist/src/index.js" for manifest in package.json package-lock.json memory-runtime.json; do @@ -676,18 +816,18 @@ verify_packaged_windows_unpacked_artifacts() { exit 1 fi verify_windows_x64_native_module \ - "$packaged_memory_runtime/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "$packaged_memory_runtime/node_modules/better-sqlite3/prebuilds/win32-x64.node" \ "packaged Memory better-sqlite3" verify_windows_x64_native_module \ - "$unpacked_runtime/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "$unpacked_runtime/memmy-agent/node_modules/better-sqlite3/prebuilds/win32-x64.node" \ "packaged memmy-agent better-sqlite3" verify_packaged_file_matches_runtime \ - "$RUNTIME_DIR/memory/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ - "$packaged_memory_runtime/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "$RUNTIME_DIR/memory/node_modules/better-sqlite3/prebuilds/win32-x64.node" \ + "$packaged_memory_runtime/node_modules/better-sqlite3/prebuilds/win32-x64.node" \ "Memory better-sqlite3 module" verify_packaged_file_matches_runtime \ - "$RUNTIME_DIR/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ - "$unpacked_runtime/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "$RUNTIME_DIR/memmy-agent/node_modules/better-sqlite3/prebuilds/win32-x64.node" \ + "$unpacked_runtime/memmy-agent/node_modules/better-sqlite3/prebuilds/win32-x64.node" \ "memmy-agent better-sqlite3 module" require_packaged_runtime_file "$packaged_memory_runtime/node_modules/onnxruntime-node/bin/napi-v3/win32/x64/onnxruntime.dll" require_packaged_runtime_glob "$packaged_memory_runtime/node_modules/onnxruntime-node/bin/napi-v3/win32/x64/*.dll" @@ -706,10 +846,14 @@ verify_packaged_windows_unpacked_artifacts() { node "$ROOT_DIR/scripts/internal/win/verify-computer-history-runtime.mjs" \ "$(to_node_readable_path "$unpacked_runtime/memmy-agent/dist/native-computer-history/windows/amd64/memmy-computer-history.exe")" x64 \ "$(to_node_readable_path "$ROOT_DIR/App/native-computer-history/apps/OpenComputerHistoryWindows/worker.ps1")" - verify_packaged_file_matches_runtime \ - "$RUNTIME_DIR/memmy-agent/dist/native-computer-history/windows/amd64/memmy-computer-history.exe" \ - "$unpacked_runtime/memmy-agent/dist/native-computer-history/windows/amd64/memmy-computer-history.exe" \ - "Windows 11 native History recorder" + if [ "$PACKAGE_SIGNING" = "unsigned" ]; then + verify_packaged_file_matches_runtime \ + "$RUNTIME_DIR/memmy-agent/dist/native-computer-history/windows/amd64/memmy-computer-history.exe" \ + "$unpacked_runtime/memmy-agent/dist/native-computer-history/windows/amd64/memmy-computer-history.exe" \ + "Windows 11 native History recorder" + else + log "Signed package: skipping byte-for-byte comparison for the Authenticode-signed Windows 11 History recorder." + fi require_packaged_runtime_file "$unpacked_runtime/memmy-agent/dist/tools/computer-history/win/win11-observer.ps1" verify_packaged_file_matches_runtime \ "$RUNTIME_DIR/memmy-agent/dist/tools/computer-history/win/win11-observer.ps1" \ @@ -774,16 +918,21 @@ npm_ci_win_x64() { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --prefix "$package_dir" --omit=dev --ignore-scripts --os=win32 --cpu=x64 } -install_better_sqlite3_win_x64() { - local runtime_dir="$1" - local electron_version - electron_version="${MEMMY_ELECTRON_VERSION:-$(read_package_version "$DESKTOP_DIR/node_modules/electron/package.json")}" +prune_better_sqlite3_win_artifacts() { + local module_dir="$1" - ( - cd "$runtime_dir/node_modules/better-sqlite3" - run_with_retries 3 ../.bin/prebuild-install --platform win32 --arch x64 --runtime electron --target "$electron_version" || - install_better_sqlite3_prebuild_with_download_fallback "$electron_version" - ) + rm -rf "$module_dir/build" "$module_dir/deps" "$module_dir/src" + if [ -d "$module_dir/prebuilds" ]; then + find "$module_dir/prebuilds" \ + -mindepth 1 -maxdepth 1 ! -name "win32-x64.node" \ + -exec rm -rf {} + + fi +} + +prune_windows_better_sqlite3_artifacts() { + while IFS= read -r module_dir; do + prune_better_sqlite3_win_artifacts "$module_dir" + done < <(find "$RUNTIME_DIR" -path "*/node_modules/better-sqlite3" -type d) } package_step_start "Validate Windows signing configuration" @@ -796,12 +945,13 @@ fi package_step_start "Install root workspace dependencies if needed" if [ ! -d "$ROOT_DIR/node_modules" ]; then log "Installing root workspace dependencies" - npm_with_configured_script_shell install + npm_with_configured_script_shell install --ignore-scripts --package-lock=false --no-audit --no-fund else log "Root workspace dependencies already available" fi +restore_electron_dist package_step_start "Install migrations workspace dev dependencies" -npm_with_configured_script_shell install --workspace @memmy/migrations --include=dev +npm_with_configured_script_shell install --workspace @memmy/migrations --include=dev --ignore-scripts --package-lock=false --no-audit --no-fund package_step_start "Build migrations package" npm_with_configured_script_shell run build --prefix "$MIGRATIONS_DIR" @@ -810,7 +960,7 @@ package_step_start "Build local API contracts package" npm_with_configured_script_shell run build -w @memmy/local-api-contracts package_step_start "Install memmy-agent workspace dependencies" -PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm_with_configured_script_shell ci --prefix "$AGENT_DIR" +PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm_with_configured_script_shell ci --prefix "$AGENT_DIR" --ignore-scripts --no-audit --no-fund --os=win32 --cpu=x64 package_step_start "Build Memory workspace" npm_with_configured_script_shell run build -w @memmy/memory @@ -846,7 +996,6 @@ create_memory_runtime_manifest package_step_start "Install Windows x64 Memory runtime dependencies" npm_ci_win_x64 "$RUNTIME_DIR/memory" -install_better_sqlite3_win_x64 "$RUNTIME_DIR/memory" package_step_start "Stage Windows Memory workspace runtime packages" RUNTIME_AGENT_SOURCE_CORE_DIR="$RUNTIME_DIR/memory/node_modules/@memmy/agent-source-core" rm -rf "$RUNTIME_AGENT_SOURCE_CORE_DIR" @@ -880,7 +1029,8 @@ cp "$AGENT_DIR/package-lock.json" "$RUNTIME_DIR/memmy-agent/package-lock.json" package_step_start "Install Windows x64 memmy-agent runtime dependencies" npm_ci_win_x64 "$RUNTIME_DIR/memmy-agent" -install_better_sqlite3_win_x64 "$RUNTIME_DIR/memmy-agent" +package_step_start "Prune Windows better-sqlite3 native artifacts" +prune_windows_better_sqlite3_artifacts package_step_start "Build Memmy Computer Use for Windows from source" bash "$ROOT_DIR/scripts/internal/win/build-memmy-computer-use.sh" \ "$RUNTIME_DIR/memmy-agent/dist/native-computer-use/windows/amd64/memmy-computer-use.exe" amd64 @@ -994,12 +1144,16 @@ cp -R "$EMBEDDING_MODELS_DIR" "$RUNTIME_DIR/memory/embedding-models" package_step_start "Patch electron-builder NSIS template" patch_electron_builder_nsis_refresh +patch_electron_builder_nsis_uninstaller_extraction package_step_start "Run electron-builder Windows NSIS packaging" cd "$DESKTOP_DIR" +mkdir -p "$BUILD_OUTPUT_DIR" +log "Using isolated electron-builder output: $BUILD_OUTPUT_DIR" BUILDER_ARGS=(--config "$BUILDER_CONFIG") BUILDER_ARGS+=(--config.extraMetadata.version="$DESKTOP_VERSION") +BUILDER_ARGS+=(--config.directories.output="$(to_node_readable_path "$BUILD_OUTPUT_DIR")") ELECTRON_DIST="$(resolve_electron_dist)" if [ -n "$ELECTRON_DIST" ]; then log "Using Electron dist: $ELECTRON_DIST" @@ -1009,13 +1163,20 @@ if [ "${#WINDOWS_SIGNING_BUILDER_ARGS[@]}" -gt 0 ]; then BUILDER_ARGS+=("${WINDOWS_SIGNING_BUILDER_ARGS[@]}") fi -npx electron-builder "${BUILDER_ARGS[@]}" --win nsis --x64 "$@" --config.artifactName="$ARTIFACT_NAME" +run_electron_builder_with_signing_recovery "$@" package_step_start "Verify packaged Windows app artifacts" verify_packaged_windows_unpacked_artifacts node "$ROOT_DIR/scripts/internal/win/verify-desktop-sqlite-vec.mjs" packaged \ "$(to_node_readable_path "$ROOT_DIR/App/backend/package.json")" \ - "$(to_node_readable_path "$DESKTOP_DIR/release/win-unpacked/resources/app.asar")" \ - "$(to_node_readable_path "$DESKTOP_DIR/release/win-unpacked/resources")" + "$(to_node_readable_path "$UNPACKED_DIR/resources/app.asar")" \ + "$(to_node_readable_path "$UNPACKED_DIR/resources")" + +package_step_start "Copy verified Windows installer to release output" +if [ ! -f "$BUILDER_FINAL_EXE" ]; then + echo "Packaging completed without the isolated installer: $BUILDER_FINAL_EXE" >&2 + exit 1 +fi +run_with_retries 5 cp -f "$BUILDER_FINAL_EXE" "$FINAL_EXE" if [ ! -f "$FINAL_EXE" ]; then echo "Packaging completed without the expected installer: $FINAL_EXE" >&2 diff --git a/scripts/internal/win/stage-wechat-python.mjs b/scripts/internal/win/stage-wechat-python.mjs index a4be495bb..0737afe04 100644 --- a/scripts/internal/win/stage-wechat-python.mjs +++ b/scripts/internal/win/stage-wechat-python.mjs @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'; import { existsSync, lstatSync, readFileSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; +import { setTimeout as sleep } from 'node:timers/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { verifyWindowsComputerUseRuntime } from './verify-computer-use-runtime.mjs'; @@ -20,6 +21,24 @@ const REQUIRED_FILES = [ 'libcrypto-3.dll', 'libffi-8.dll', 'vcruntime140.dll', 'vcruntime140_1.dll', ]; const IMMUTABLE_FILES = ['python313.zip', 'python313._pth', 'LICENSE.txt']; +const WINDOWS_DIRECTORY_REPLACE_RETRIES = 5; + +async function replaceDirectoryWithRetry(staging, destination) { + let lastError; + for (let attempt = 1; attempt <= WINDOWS_DIRECTORY_REPLACE_RETRIES; attempt += 1) { + try { + await rm(destination, { recursive: true, force: true }); + await rename(staging, destination); + return; + } catch (error) { + lastError = error; + const retryable = ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code); + if (!retryable || attempt === WINDOWS_DIRECTORY_REPLACE_RETRIES) throw error; + await sleep(attempt * 500); + } + } + throw lastError; +} export function verifyPinnedWindowsPythonArchive(bytes) { if (createHash('sha256').update(bytes).digest('hex') !== WINDOWS_WECHAT_PYTHON_SHA256) { @@ -96,8 +115,7 @@ export async function stageWindowsWeChatPython({ destination, archivePath, createHash('sha256').update(readFileSync(join(staging, name))).digest('hex')])), }, null, 2)}\n`); verifyWindowsWeChatPythonDirectory(staging); - await rm(destination, { recursive: true, force: true }); - await rename(staging, destination); + await replaceDirectoryWithRetry(staging, destination); } finally { await rm(staging, { recursive: true, force: true }); } } diff --git a/tests/linux-cli-packaging.test.mjs b/tests/linux-cli-packaging.test.mjs index eaa86057d..cac41c8f3 100644 --- a/tests/linux-cli-packaging.test.mjs +++ b/tests/linux-cli-packaging.test.mjs @@ -171,6 +171,17 @@ function makeInstallerFixture(root) { function fakeLinuxTools(root) { const tools = path.join(root, "tools"); mkdirSync(tools, { recursive: true }); + const realNode = process.execPath.replaceAll("'", "'\\''"); + const node = path.join(tools, "node"); + writeFileSync(node, [ + "#!/usr/bin/env bash", + "if [ \"${1:-}\" = \"-e\" ] && [ -n \"${MEMMY_FIXTURE_NODE_VERSION:-}\" ]; then", + ` exec '${realNode}' -e \"Object.defineProperty(process.versions, 'node', { value: '$MEMMY_FIXTURE_NODE_VERSION' }); $2\"`, + "fi", + `exec '${realNode}' \"$@\"`, + "", + ].join("\n")); + chmodSync(node, 0o755); const uname = path.join(tools, "uname"); writeFileSync(uname, [ "#!/usr/bin/env bash", @@ -225,6 +236,7 @@ function runInstaller(home, release, tools, overrides = {}) { MEMMY_INSTALL_COMPUTER_USE_DEPS: "0", MEMMY_RELEASE_BASE_URL: pathToFileURL(release).href.replace(/\/$/, ""), MEMMY_FIXTURE_MAIN_PID: String(process.pid), + MEMMY_FIXTURE_NODE_VERSION: "22.23.4", PATH: `${tools}${path.delimiter}${process.env.PATH ?? ""}`, ...overrides, }), @@ -267,7 +279,7 @@ describe("Linux CLI package boundary", () => { expect(installer).toContain("gateway --config %s --workspace %s"); expect(installer).toContain("x86_64|amd64"); expect(installer).toContain("aarch64|arm64"); - expect(installer).toContain("Node.js 22 or newer is required"); + expect(installer).toContain("Node.js 22.23.4, 24.22.1 or 26.12.1 or newer is required"); expect(installer).not.toMatch(/nohup|disown|pkill|killall|enable-linger/); }); @@ -443,6 +455,34 @@ describe("Linux CLI package boundary", () => { }); describe("Linux one-line installer transaction", () => { + it("rejects a host Node release without the fixed root store", () => { + const root = temporaryRoot("memmy-linux-installer-node-gate-"); + const release = path.join(root, "release"); + const tools = fakeLinuxTools(root); + const home = path.join(root, "home"); + mkdirSync(release); + mkdirSync(home); + + const result = runInstaller(home, release, tools, { MEMMY_FIXTURE_NODE_VERSION: "22.23.3" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("earlier releases still trust the compromised eMudhra roots"); + }); + + it("rejects unverified future Node major lines", () => { + const root = temporaryRoot("memmy-linux-installer-node-future-"); + const release = path.join(root, "release"); + const tools = fakeLinuxTools(root); + mkdirSync(release); + for (const version of ["27.0.0", "28.0.0"]) { + const home = path.join(root, `home-${version}`); + mkdirSync(home); + const result = runInstaller(home, release, tools, { MEMMY_FIXTURE_NODE_VERSION: version }); + expect(result.status, version).not.toBe(0); + expect(result.stderr, version).toContain("earlier releases still trust the compromised eMudhra roots"); + } + }); + it("accepts stable and rc release versions while rejecting unsafe or malformed versions", () => { const root = temporaryRoot("memmy-linux-installer-version-"); const release = path.join(root, "release"); diff --git a/tests/packaged-runtime-config.test.mjs b/tests/packaged-runtime-config.test.mjs index e58e7ae2f..546cb825c 100644 --- a/tests/packaged-runtime-config.test.mjs +++ b/tests/packaged-runtime-config.test.mjs @@ -28,7 +28,8 @@ afterEach(() => { while (roots.length) rmSync(roots.pop(), { recursive: true, force: true }); }); -describe("packaged desktop runtime configuration", () => { +// These cases stage ASAR fixtures and launch independent verification processes. +describe("packaged desktop runtime configuration", { timeout: 20_000 }, () => { it("writes exactly the public allowlist and never serializes env decoys", async () => { const root = fixtureRoot(); const envFile = join(root, ".env"); @@ -659,7 +660,7 @@ await import(pathToFileURL(process.argv[1]).href); buildScript.indexOf('RUNTIME_AGENT_SOURCE_CORE_DIR="$RUNTIME_DIR/memory/node_modules/@memmy/agent-source-core"'), ); expect(buildScript).toContain( - 'local packaged_memory_runtime="$DESKTOP_DIR/release/win-unpacked/resources/memory-runtime"', + 'local packaged_memory_runtime="$UNPACKED_DIR/resources/memory-runtime"', ); expect(buildScript).toContain( 'require_packaged_runtime_file "$packaged_agent_source_core/package.json"', diff --git a/tests/prune-native-platforms.test.mjs b/tests/prune-native-platforms.test.mjs index bc76cf2e9..928f2e141 100644 --- a/tests/prune-native-platforms.test.mjs +++ b/tests/prune-native-platforms.test.mjs @@ -94,8 +94,13 @@ describe("packaged node-pty target selection", () => { expect(JSON.parse(result.stdout).removedPackages).toHaveLength(15); for (const path of ["../scripts/internal/mac/build-dmg.sh", "../scripts/internal/win/build-nsis.sh"]) { const source = readFileSync(new URL(path, import.meta.url), "utf8"); + // Windows invokes the signing wrapper from the main packaging flow; + // the npx command inside its earlier declaration is not execution order. + const signingWrapperCall = source.lastIndexOf('\nrun_electron_builder_with_signing_recovery "$@"'); + const packageCall = signingWrapperCall >= 0 ? signingWrapperCall : source.lastIndexOf("npx electron-builder"); expect(source.indexOf("prune-native-platforms.mjs")).toBeGreaterThan(-1); - expect(source.indexOf("prune-native-platforms.mjs")).toBeLessThan(source.lastIndexOf("npx electron-builder")); + expect(packageCall).toBeGreaterThan(-1); + expect(source.indexOf("prune-native-platforms.mjs")).toBeLessThan(packageCall); } }); diff --git a/tests/renderer-bootstrap.test.mjs b/tests/renderer-bootstrap.test.mjs index df986b72b..37402f416 100644 --- a/tests/renderer-bootstrap.test.mjs +++ b/tests/renderer-bootstrap.test.mjs @@ -68,7 +68,9 @@ function writeRealReactFixture({ duplicate = false, catchRenderError = false } = return root; } -describe('production renderer bootstrap guard', () => { +// The child probe has its own 15-second deadline; allow it to finish and report +// the expected rejection before the outer test times out on a busy build host. +describe('production renderer bootstrap guard', { timeout: 20_000 }, () => { it.each(['darwin', 'win32'])('executes real production React startup with one shared dispatcher on %s', platform => { const result = verify(writeRealReactFixture(), platform); expect(result.status).toBe(0); diff --git a/tests/windows-desktop-sqlite-vec.test.mjs b/tests/windows-desktop-sqlite-vec.test.mjs index 498e8ab29..4412f8ad2 100644 --- a/tests/windows-desktop-sqlite-vec.test.mjs +++ b/tests/windows-desktop-sqlite-vec.test.mjs @@ -109,8 +109,10 @@ it("checks the desktop vector binding before and after Windows packaging", () => const script = readFileSync(new URL("../scripts/internal/win/build-nsis.sh", import.meta.url), "utf8"); const first = script.indexOf('verify-desktop-sqlite-vec.mjs" staged'); const second = script.indexOf('verify-desktop-sqlite-vec.mjs" packaged'); + const packageCall = script.lastIndexOf('\nrun_electron_builder_with_signing_recovery "$@"'); + expect(packageCall).toBeGreaterThan(-1); expect(first).toBeGreaterThan(script.indexOf('npm_ci_win_x64 "$RUNTIME_DIR/memory"')); - expect(first).toBeLessThan(script.indexOf("npx electron-builder")); - expect(second).toBeGreaterThan(script.indexOf("npx electron-builder")); + expect(first).toBeLessThan(packageCall); + expect(second).toBeGreaterThan(packageCall); expect(existsSync(new URL("../scripts/internal/win/verify-desktop-sqlite-vec.mjs", import.meta.url))).toBe(true); });