From fdea07a5338839a6240e7352319e3afd63ecc53d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:36:39 -0700 Subject: [PATCH 01/32] chore: Create wallet-cli package --- README.md | 2 + packages/wallet-cli/CHANGELOG.md | 10 ++++ packages/wallet-cli/LICENSE | 20 +++++++ packages/wallet-cli/README.md | 15 ++++++ packages/wallet-cli/jest.config.js | 26 +++++++++ packages/wallet-cli/package.json | 70 +++++++++++++++++++++++++ packages/wallet-cli/src/index.test.ts | 9 ++++ packages/wallet-cli/src/index.ts | 9 ++++ packages/wallet-cli/tsconfig.build.json | 10 ++++ packages/wallet-cli/tsconfig.json | 8 +++ packages/wallet-cli/typedoc.json | 7 +++ tsconfig.build.json | 3 ++ tsconfig.json | 3 ++ yarn.lock | 17 ++++++ 14 files changed, 209 insertions(+) create mode 100644 packages/wallet-cli/CHANGELOG.md create mode 100644 packages/wallet-cli/LICENSE create mode 100644 packages/wallet-cli/README.md create mode 100644 packages/wallet-cli/jest.config.js create mode 100644 packages/wallet-cli/package.json create mode 100644 packages/wallet-cli/src/index.test.ts create mode 100644 packages/wallet-cli/src/index.ts create mode 100644 packages/wallet-cli/tsconfig.build.json create mode 100644 packages/wallet-cli/tsconfig.json create mode 100644 packages/wallet-cli/typedoc.json diff --git a/README.md b/README.md index fd6fa92f4ec..e6ce9461b03 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/transaction-pay-controller`](packages/transaction-pay-controller) - [`@metamask/user-operation-controller`](packages/user-operation-controller) - [`@metamask/wallet`](packages/wallet) +- [`@metamask/wallet-cli`](packages/wallet-cli) @@ -197,6 +198,7 @@ linkStyle default opacity:0.5 transaction_pay_controller(["@metamask/transaction-pay-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); wallet(["@metamask/wallet"]); + wallet_cli(["@metamask/wallet-cli"]); account_tree_controller --> accounts_controller; account_tree_controller --> base_controller; account_tree_controller --> keyring_controller; diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/wallet-cli/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/LICENSE b/packages/wallet-cli/LICENSE new file mode 100644 index 00000000000..c8a0ff6be3a --- /dev/null +++ b/packages/wallet-cli/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/wallet-cli/README.md b/packages/wallet-cli/README.md new file mode 100644 index 00000000000..1de99a32fa7 --- /dev/null +++ b/packages/wallet-cli/README.md @@ -0,0 +1,15 @@ +# `@metamask/wallet-cli` + +The CLI of @metamask/wallet + +## Installation + +`yarn add @metamask/wallet-cli` + +or + +`npm install @metamask/wallet-cli` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/wallet-cli/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json new file mode 100644 index 00000000000..77d39138380 --- /dev/null +++ b/packages/wallet-cli/package.json @@ -0,0 +1,70 @@ +{ + "name": "@metamask/wallet-cli", + "version": "0.0.0", + "description": "The CLI of @metamask/wallet", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/wallet-cli#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/wallet-cli", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/wallet-cli", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/wallet-cli/src/index.test.ts b/packages/wallet-cli/src/index.test.ts new file mode 100644 index 00000000000..bc062d3694a --- /dev/null +++ b/packages/wallet-cli/src/index.test.ts @@ -0,0 +1,9 @@ +import greeter from '.'; + +describe('Test', () => { + it('greets', () => { + const name = 'Huey'; + const result = greeter(name); + expect(result).toBe('Hello, Huey!'); + }); +}); diff --git a/packages/wallet-cli/src/index.ts b/packages/wallet-cli/src/index.ts new file mode 100644 index 00000000000..6972c117292 --- /dev/null +++ b/packages/wallet-cli/src/index.ts @@ -0,0 +1,9 @@ +/** + * Example function that returns a greeting for the given name. + * + * @param name - The name to greet. + * @returns The greeting. + */ +export default function greeter(name: string): string { + return `Hello, ${name}!`; +} diff --git a/packages/wallet-cli/tsconfig.build.json b/packages/wallet-cli/tsconfig.build.json new file mode 100644 index 00000000000..02a0eea03fe --- /dev/null +++ b/packages/wallet-cli/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/wallet-cli/tsconfig.json b/packages/wallet-cli/tsconfig.json new file mode 100644 index 00000000000..025ba2ef7f4 --- /dev/null +++ b/packages/wallet-cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/wallet-cli/typedoc.json b/packages/wallet-cli/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/wallet-cli/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 1604f654f55..328ab04d27d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -256,6 +256,9 @@ { "path": "./packages/user-operation-controller/tsconfig.build.json" }, + { + "path": "./packages/wallet-cli/tsconfig.build.json" + }, { "path": "./packages/wallet/tsconfig.build.json" } diff --git a/tsconfig.json b/tsconfig.json index 9ca28b560c8..d5416719eeb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -247,6 +247,9 @@ }, { "path": "./packages/wallet" + }, + { + "path": "./packages/wallet-cli" } ], "files": [], diff --git a/yarn.lock b/yarn.lock index 62b1c692471..7117c03220b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5812,6 +5812,23 @@ __metadata: languageName: node linkType: hard +"@metamask/wallet-cli@workspace:packages/wallet-cli": + version: 0.0.0-use.local + resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" From 95f0f474ba824f7ef638075b35baf4e6add41eae Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:29:55 -0700 Subject: [PATCH 02/32] chore: oclif init --- packages/wallet-cli/bin/dev.cmd | 3 + packages/wallet-cli/bin/dev.mjs | 5 + packages/wallet-cli/bin/run.cmd | 3 + packages/wallet-cli/bin/run.mjs | 5 + packages/wallet-cli/package.json | 12 ++ yarn.lock | 210 +++++++++++++++++++++++++++---- 6 files changed, 217 insertions(+), 21 deletions(-) create mode 100644 packages/wallet-cli/bin/dev.cmd create mode 100755 packages/wallet-cli/bin/dev.mjs create mode 100644 packages/wallet-cli/bin/run.cmd create mode 100755 packages/wallet-cli/bin/run.mjs diff --git a/packages/wallet-cli/bin/dev.cmd b/packages/wallet-cli/bin/dev.cmd new file mode 100644 index 00000000000..ee0f58bfe9b --- /dev/null +++ b/packages/wallet-cli/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +node --loader tsx --no-warnings=ExperimentalWarning "%~dp0\dev" %* diff --git a/packages/wallet-cli/bin/dev.mjs b/packages/wallet-cli/bin/dev.mjs new file mode 100755 index 00000000000..7be01ea0e7b --- /dev/null +++ b/packages/wallet-cli/bin/dev.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env -S node --loader tsx --disable-warning=ExperimentalWarning + +import { execute } from '@oclif/core'; + +await execute({ development: true, dir: import.meta.url }); diff --git a/packages/wallet-cli/bin/run.cmd b/packages/wallet-cli/bin/run.cmd new file mode 100644 index 00000000000..968fc30758e --- /dev/null +++ b/packages/wallet-cli/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* diff --git a/packages/wallet-cli/bin/run.mjs b/packages/wallet-cli/bin/run.mjs new file mode 100755 index 00000000000..176d2af58c5 --- /dev/null +++ b/packages/wallet-cli/bin/run.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { execute } from '@oclif/core'; + +await execute({ dir: import.meta.url }); diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 77d39138380..3b1154321d6 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -31,6 +31,9 @@ }, "main": "./dist/index.cjs", "types": "./dist/index.d.cts", + "bin": { + "mm": "./bin/run.mjs" + }, "files": [ "dist/" ], @@ -48,6 +51,9 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@oclif/core": "^4.10.5" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", @@ -66,5 +72,11 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" + }, + "oclif": { + "bin": "mm", + "commands": "./dist/commands", + "dirname": "mm", + "topicSeparator": " " } } diff --git a/yarn.lock b/yarn.lock index 7117c03220b..c023a835650 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2947,6 +2947,21 @@ __metadata: languageName: unknown linkType: soft +"@metamask/auto-changelog@npm:^3.4.4": + version: 3.4.4 + resolution: "@metamask/auto-changelog@npm:3.4.4" + dependencies: + diff: "npm:^5.0.0" + execa: "npm:^5.1.1" + prettier: "npm:^2.8.8" + semver: "npm:^7.3.5" + yargs: "npm:^17.0.1" + bin: + auto-changelog: dist/cli.js + checksum: 10/70e98529a153ebeab10410dbc3f567014999f77ed82f2b52f1b36501b28a4e3614c809a90c89600a739d7710595bfecc30e2260410e6afac7539f8db65a48f2c + languageName: node + linkType: hard + "@metamask/auto-changelog@npm:^6.1.0": version: 6.1.0 resolution: "@metamask/auto-changelog@npm:6.1.0" @@ -5817,6 +5832,7 @@ __metadata: resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@oclif/core": "npm:^4.10.5" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" @@ -5826,6 +5842,8 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + bin: + mm: ./bin/run.mjs languageName: unknown linkType: soft @@ -6110,6 +6128,32 @@ __metadata: languageName: node linkType: hard +"@oclif/core@npm:^4.10.5": + version: 4.10.5 + resolution: "@oclif/core@npm:4.10.5" + dependencies: + ansi-escapes: "npm:^4.3.2" + ansis: "npm:^3.17.0" + clean-stack: "npm:^3.0.1" + cli-spinners: "npm:^2.9.2" + debug: "npm:^4.4.3" + ejs: "npm:^3.1.10" + get-package-type: "npm:^0.1.0" + indent-string: "npm:^4.0.0" + is-wsl: "npm:^2.2.0" + lilconfig: "npm:^3.1.3" + minimatch: "npm:^10.2.5" + semver: "npm:^7.7.3" + string-width: "npm:^4.2.3" + supports-color: "npm:^8" + tinyglobby: "npm:^0.2.14" + widest-line: "npm:^3.1.0" + wordwrap: "npm:^1.0.0" + wrap-ansi: "npm:^7.0.0" + checksum: 10/c92960f4975675cb8144cf466047be0ffb91fcbe42c0329037f21ab79e61bbd33afb7aacc2d9e1f6293f32af0460941dcaa76166f20505308a9f321baed78b30 + languageName: node + linkType: hard + "@octokit/auth-token@npm:^4.0.0": version: 4.0.0 resolution: "@octokit/auth-token@npm:4.0.0" @@ -7606,7 +7650,7 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^4.2.1": +"ansi-escapes@npm:^4.2.1, ansi-escapes@npm:^4.3.2": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" dependencies: @@ -7659,6 +7703,13 @@ __metadata: languageName: node linkType: hard +"ansis@npm:^3.17.0": + version: 3.17.0 + resolution: "ansis@npm:3.17.0" + checksum: 10/6fd6bc4d1187b894d9706f4c141c81b788e90766426617385486dae38f8b2f5a1726d8cc754939e44265f92a9db4647d5136cb1425435c39ac42b35e3acf4f3d + languageName: node + linkType: hard + "anymatch@npm:^3.0.3": version: 3.1.3 resolution: "anymatch@npm:3.1.3" @@ -7773,6 +7824,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.6": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: 10/cb6e0561a3c01c4b56a799cc8bab6ea5fef45f069ab32500b6e19508db270ef2dffa55e5aed5865c5526e9907b1f8be61b27530823b411ffafb5e1538c86c368 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -7894,6 +7952,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0 + languageName: node + linkType: hard + "bare-events@npm:^2.2.0": version: 2.4.2 resolution: "bare-events@npm:2.4.2" @@ -8087,6 +8152,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.5": + version: 5.0.5 + resolution: "brace-expansion@npm:5.0.5" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10/f259b2ddf04489da9512ad637ba6b4ef2d77abd4445d20f7f1714585f153435200a53fa6a2e4a5ee974df14ddad4cd16421f6f803e96e8b452bd48598878d0ee + languageName: node + linkType: hard + "braces@npm:^3.0.3": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -8397,7 +8471,16 @@ __metadata: languageName: node linkType: hard -"cli-spinners@npm:^2.6.0": +"clean-stack@npm:^3.0.1": + version: 3.0.1 + resolution: "clean-stack@npm:3.0.1" + dependencies: + escape-string-regexp: "npm:4.0.0" + checksum: 10/dc18c842d7792dd72d463936b1b0a5b2621f0fc11588ee48b602e1a29b6c010c606d89f3de1f95d15d72de74aea93c0fbac8246593a31d95f8462cac36148e05 + languageName: node + linkType: hard + +"cli-spinners@npm:^2.6.0, cli-spinners@npm:^2.9.2": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" checksum: 10/a0a863f442df35ed7294424f5491fa1756bd8d2e4ff0c8736531d886cec0ece4d85e8663b77a5afaf1d296e3cbbebff92e2e99f52bbea89b667cbe789b994794 @@ -9082,6 +9165,17 @@ __metadata: languageName: node linkType: hard +"ejs@npm:^3.1.10": + version: 3.1.10 + resolution: "ejs@npm:3.1.10" + dependencies: + jake: "npm:^10.8.5" + bin: + ejs: bin/cli.js + checksum: 10/a9cb7d7cd13b7b1cd0be5c4788e44dd10d92f7285d2f65b942f33e127230c054f99a42db4d99f766d8dbc6c57e94799593ee66a14efd7c8dd70c4812bf6aa384 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.5.73": version: 1.5.79 resolution: "electron-to-chromium@npm:1.5.79" @@ -9337,6 +9431,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:4.0.0, escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 10/98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 + languageName: node + linkType: hard + "escape-string-regexp@npm:^2.0.0": version: 2.0.0 resolution: "escape-string-regexp@npm:2.0.0" @@ -9344,13 +9445,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^4.0.0": - version: 4.0.0 - resolution: "escape-string-regexp@npm:4.0.0" - checksum: 10/98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 - languageName: node - linkType: hard - "escodegen@npm:^2.0.0": version: 2.1.0 resolution: "escodegen@npm:2.1.0" @@ -10189,6 +10283,15 @@ __metadata: languageName: node linkType: hard +"filelist@npm:^1.0.4": + version: 1.0.6 + resolution: "filelist@npm:1.0.6" + dependencies: + minimatch: "npm:^5.0.1" + checksum: 10/84a0be69efe6724c105f18c34e8a772370d9c45e53a1ba8ced7eecf4addd2c5a357347d94bfd8bfa9cbc36b09392cad70d82206305263e26bba184eea4ca8042 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -11079,6 +11182,15 @@ __metadata: languageName: node linkType: hard +"is-docker@npm:^2.0.0": + version: 2.2.1 + resolution: "is-docker@npm:2.2.1" + bin: + is-docker: cli.js + checksum: 10/3fef7ddbf0be25958e8991ad941901bf5922ab2753c46980b60b05c1bf9c9c2402d35e6dc32e4380b980ef5e1970a5d9d5e5aa2e02d77727c3b6b5e918474c56 + languageName: node + linkType: hard + "is-docker@npm:^3.0.0": version: 3.0.0 resolution: "is-docker@npm:3.0.0" @@ -11206,6 +11318,15 @@ __metadata: languageName: node linkType: hard +"is-wsl@npm:^2.2.0": + version: 2.2.0 + resolution: "is-wsl@npm:2.2.0" + dependencies: + is-docker: "npm:^2.0.0" + checksum: 10/20849846ae414997d290b75e16868e5261e86ff5047f104027026fd61d8b5a9b0b3ade16239f35e1a067b3c7cc02f70183cb661010ed16f4b6c7c93dad1b19d8 + languageName: node + linkType: hard + "is-wsl@npm:^3.1.0": version: 3.1.0 resolution: "is-wsl@npm:3.1.0" @@ -11333,6 +11454,19 @@ __metadata: languageName: node linkType: hard +"jake@npm:^10.8.5": + version: 10.9.4 + resolution: "jake@npm:10.9.4" + dependencies: + async: "npm:^3.2.6" + filelist: "npm:^1.0.4" + picocolors: "npm:^1.1.1" + bin: + jake: bin/cli.js + checksum: 10/97e48f73f5e315a3b6e1a48b4bcc0cdf2c2cf82100ec9e76a032fd5d614dcd32c4315572cfcb66e9f9bdecca3900aaa61fe72b781a74b06aefd3ec4c1c917f0b + languageName: node + linkType: hard + "jest-changed-files@npm:^29.7.0": version: 29.7.0 resolution: "jest-changed-files@npm:29.7.0" @@ -12112,6 +12246,13 @@ __metadata: languageName: node linkType: hard +"lilconfig@npm:^3.1.3": + version: 3.1.3 + resolution: "lilconfig@npm:3.1.3" + checksum: 10/b932ce1af94985f0efbe8896e57b1f814a48c8dbd7fc0ef8469785c6303ed29d0090af3ccad7e36b626bfca3a4dc56cc262697e9a8dd867623cf09a39d54e4c3 + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -12512,6 +12653,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.5": + version: 10.2.5 + resolution: "minimatch@npm:10.2.5" + dependencies: + brace-expansion: "npm:^5.0.5" + checksum: 10/19e87a931aff60ee7b9d80f39f817b8bfc54f61f8356ee3549fbf636dbccacacfec8d803eac73293955c4527cd085247dfc064bce4a5e349f8f3b85e2bf5da0f + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -12521,6 +12671,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^5.0.1": + version: 5.1.9 + resolution: "minimatch@npm:5.1.9" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10/23b4feb64dcb77ba93b70a72be551eb2e2677ac02178cf1ed3d38836cc4cd84802d90b77f60ef87f2bac64d270d2d8eba242e428f0554ea4e36bfdb7e9d25d0c + languageName: node + linkType: hard + "minimatch@npm:^7.4.6": version: 7.4.6 resolution: "minimatch@npm:7.4.6" @@ -13364,10 +13523,10 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5 +"picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10/f6ef80a3590827ce20378ae110ac78209cc4f74d39236370f1780f957b7ee41c12acde0e4651b90f39983506fd2f5e449994716f516db2e9752924aff8de93ce languageName: node linkType: hard @@ -13459,7 +13618,7 @@ __metadata: languageName: node linkType: hard -"prettier-2@npm:prettier@^2.8.8": +"prettier-2@npm:prettier@^2.8.8, prettier@npm:^2.8.8": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: @@ -14538,7 +14697,7 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -14665,7 +14824,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^8.0.0": +"supports-color@npm:^8, supports-color@npm:^8.0.0": version: 8.1.1 resolution: "supports-color@npm:8.1.1" dependencies: @@ -14799,13 +14958,13 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.15": - version: 0.2.15 - resolution: "tinyglobby@npm:0.2.15" +"tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": + version: 0.2.16 + resolution: "tinyglobby@npm:0.2.16" dependencies: fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.3" - checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2 + picomatch: "npm:^4.0.4" + checksum: 10/5c2c41b572ada38449e7c86a5fe034f204a1dbba577225a761a14f29f48dc3f2fc0d81a6c56fcc67c5a742cc3aa9fb5e2ca18dbf22b610b0bc0e549b34d5a0f8 languageName: node linkType: hard @@ -15528,6 +15687,15 @@ __metadata: languageName: node linkType: hard +"widest-line@npm:^3.1.0": + version: 3.1.0 + resolution: "widest-line@npm:3.1.0" + dependencies: + string-width: "npm:^4.0.0" + checksum: 10/03db6c9d0af9329c37d74378ff1d91972b12553c7d72a6f4e8525fe61563fa7adb0b9d6e8d546b7e059688712ea874edd5ded475999abdeedf708de9849310e0 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" From 539007d19d6c57e926941162bf93d376135bff22 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:27:28 -0700 Subject: [PATCH 03/32] feat(wallet-cli): Implement daemon for running wallet as background process Adds a daemon that runs a Wallet instance as a detached background process, communicating over JSON-RPC via Unix domain sockets. This mirrors the architecture of kernel-cli's daemon. Infrastructure (src/daemon/): - socket-line: newline-delimited socket I/O - rpc-socket-server: generic Unix socket JSON-RPC server - daemon-client: one-shot JSON-RPC client with retry - daemon-entry: standalone entry point for the spawned process - daemon-spawn: spawns daemon-entry as detached child - stop-daemon: shared stop logic with escalation - wallet-factory: creates configured Wallet from config - utils, paths, types: process utilities and path resolution Commands (src/commands/daemon/): - start: start daemon (--infura-project-id or INFURA_PROJECT_ID env) - stop: stop daemon (RPC shutdown -> SIGTERM -> SIGKILL) - status: check daemon status - purge: stop daemon and delete all state files Co-Authored-By: Claude Opus 4.6 --- eslint.config.mjs | 13 ++ packages/wallet-cli/bin/dev.mjs | 2 - packages/wallet-cli/package.json | 5 + .../wallet-cli/src/commands/daemon/purge.ts | 44 ++++ .../wallet-cli/src/commands/daemon/start.ts | 38 ++++ .../wallet-cli/src/commands/daemon/status.ts | 50 +++++ .../wallet-cli/src/commands/daemon/stop.ts | 22 ++ .../wallet-cli/src/daemon/daemon-client.ts | 107 +++++++++ .../wallet-cli/src/daemon/daemon-entry.ts | 116 ++++++++++ .../wallet-cli/src/daemon/daemon-spawn.ts | 78 +++++++ packages/wallet-cli/src/daemon/paths.ts | 17 ++ .../src/daemon/rpc-socket-server.ts | 205 ++++++++++++++++++ packages/wallet-cli/src/daemon/socket-line.ts | 86 ++++++++ packages/wallet-cli/src/daemon/stop-daemon.ts | 75 +++++++ packages/wallet-cli/src/daemon/types.ts | 31 +++ packages/wallet-cli/src/daemon/utils.ts | 120 ++++++++++ .../wallet-cli/src/daemon/wallet-factory.ts | 38 ++++ packages/wallet-cli/tsconfig.build.json | 9 +- packages/wallet-cli/tsconfig.json | 11 +- yarn.lock | 6 +- 20 files changed, 1067 insertions(+), 6 deletions(-) create mode 100644 packages/wallet-cli/src/commands/daemon/purge.ts create mode 100644 packages/wallet-cli/src/commands/daemon/start.ts create mode 100644 packages/wallet-cli/src/commands/daemon/status.ts create mode 100644 packages/wallet-cli/src/commands/daemon/stop.ts create mode 100644 packages/wallet-cli/src/daemon/daemon-client.ts create mode 100644 packages/wallet-cli/src/daemon/daemon-entry.ts create mode 100644 packages/wallet-cli/src/daemon/daemon-spawn.ts create mode 100644 packages/wallet-cli/src/daemon/paths.ts create mode 100644 packages/wallet-cli/src/daemon/rpc-socket-server.ts create mode 100644 packages/wallet-cli/src/daemon/socket-line.ts create mode 100644 packages/wallet-cli/src/daemon/stop-daemon.ts create mode 100644 packages/wallet-cli/src/daemon/types.ts create mode 100644 packages/wallet-cli/src/daemon/utils.ts create mode 100644 packages/wallet-cli/src/daemon/wallet-factory.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 6e9b5868bd6..82abb621748 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -300,6 +300,19 @@ const config = createConfig([ 'n/no-deprecated-api': 'off', }, }, + { + files: ['packages/wallet-cli/src/**/*.{js,ts}'], + rules: { + 'import-x/no-nodejs-modules': 'off', + 'no-restricted-globals': 'off', + }, + }, + { + files: ['packages/wallet-cli/bin/**/*.mjs'], + rules: { + 'import-x/no-unresolved': 'off', + }, + }, { files: ['packages/messenger/src/generate-action-types/**/*.{js,ts}'], rules: { diff --git a/packages/wallet-cli/bin/dev.mjs b/packages/wallet-cli/bin/dev.mjs index 7be01ea0e7b..857ef9d96b8 100755 --- a/packages/wallet-cli/bin/dev.mjs +++ b/packages/wallet-cli/bin/dev.mjs @@ -1,5 +1,3 @@ -#!/usr/bin/env -S node --loader tsx --disable-warning=ExperimentalWarning - import { execute } from '@oclif/core'; await execute({ development: true, dir: import.meta.url }); diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 3b1154321d6..7400120ce75 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -35,6 +35,7 @@ "mm": "./bin/run.mjs" }, "files": [ + "bin/", "dist/" ], "scripts": { @@ -52,6 +53,10 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/remote-feature-flag-controller": "^4.2.0", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/utils": "^11.9.0", + "@metamask/wallet": "^0.0.0", "@oclif/core": "^4.10.5" }, "devDependencies": { diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts new file mode 100644 index 00000000000..6f4f113dbad --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -0,0 +1,44 @@ +import { Command, Flags } from '@oclif/core'; +import { rm } from 'node:fs/promises'; + +import { getDaemonPaths } from '../../daemon/paths'; +import { stopDaemon } from '../../daemon/stop-daemon'; + +export default class DaemonPurge extends Command { + static override description = + 'Stop the daemon and delete all daemon state files'; + + static override examples = ['<%= config.bin %> daemon purge --force']; + + static override flags = { + force: Flags.boolean({ + char: 'f', + description: 'Required to confirm purge', + required: true, + }), + }; + + public async run(): Promise { + await this.parse(DaemonPurge); + + const { socketPath, pidPath, logPath } = getDaemonPaths( + this.config.dataDir, + ); + + const stopped = await stopDaemon(socketPath, pidPath, (message) => + this.log(message), + ); + + if (!stopped) { + this.error('Refusing to delete state while the daemon is still running.'); + } + + await Promise.all([ + rm(socketPath, { force: true }), + rm(pidPath, { force: true }), + rm(logPath, { force: true }), + ]); + + this.log('All daemon state deleted.'); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts new file mode 100644 index 00000000000..92712778f66 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -0,0 +1,38 @@ +import { Command, Flags } from '@oclif/core'; + +import { ensureDaemon } from '../../daemon/daemon-spawn'; +import { getDaemonPaths } from '../../daemon/paths'; + +export default class DaemonStart extends Command { + static override description = 'Start the wallet daemon'; + + static override examples = [ + '<%= config.bin %> daemon start --infura-project-id ', + 'INFURA_PROJECT_ID= <%= config.bin %> daemon start', + ]; + + static override flags = { + 'infura-project-id': Flags.string({ + description: 'Infura project ID for network access', + env: 'INFURA_PROJECT_ID', + required: true, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DaemonStart); + const infuraProjectId = flags['infura-project-id']; + + const { logPath, socketPath } = getDaemonPaths(this.config.dataDir); + + await ensureDaemon(socketPath, { + dataDir: this.config.dataDir, + socketPath, + logPath, + infuraProjectId, + packageRoot: this.config.root, + }); + + this.log(`Daemon running. Socket: ${socketPath}`); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/status.ts b/packages/wallet-cli/src/commands/daemon/status.ts new file mode 100644 index 00000000000..d2d144ef1cd --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -0,0 +1,50 @@ +import { isJsonRpcFailure } from '@metamask/utils'; +import { Command } from '@oclif/core'; + +import { pingDaemon, sendCommand } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import { isProcessAlive, readPidFile } from '../../daemon/utils'; + +export default class DaemonStatus extends Command { + static override description = 'Check the status of the wallet daemon'; + + static override examples = ['<%= config.bin %> daemon status']; + + public async run(): Promise { + const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); + + const pid = await readPidFile(pidPath); + const processAlive = pid !== undefined && isProcessAlive(pid); + const socketResponsive = await pingDaemon(socketPath); + + if (!processAlive && !socketResponsive) { + this.log('Daemon is not running.'); + return; + } + + if (processAlive && !socketResponsive) { + this.log( + `Daemon process exists (PID: ${pid}) but socket is not responding.`, + ); + return; + } + + const response = await sendCommand({ + socketPath, + method: 'getStatus', + timeoutMs: 5_000, + }); + + if (isJsonRpcFailure(response)) { + this.log( + `Daemon is running but returned an error: ${response.error.message}`, + ); + return; + } + + const status = response.result as { pid: number; uptime: number }; + this.log( + `Daemon is running. PID: ${status.pid}, Uptime: ${status.uptime}s`, + ); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/stop.ts b/packages/wallet-cli/src/commands/daemon/stop.ts new file mode 100644 index 00000000000..5df3eb64b1a --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/stop.ts @@ -0,0 +1,22 @@ +import { Command } from '@oclif/core'; + +import { getDaemonPaths } from '../../daemon/paths'; +import { stopDaemon } from '../../daemon/stop-daemon'; + +export default class DaemonStop extends Command { + static override description = 'Stop the wallet daemon'; + + static override examples = ['<%= config.bin %> daemon stop']; + + public async run(): Promise { + const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); + + const stopped = await stopDaemon(socketPath, pidPath, (message) => + this.log(message), + ); + + if (!stopped) { + this.error('Daemon did not stop within timeout.'); + } + } +} diff --git a/packages/wallet-cli/src/daemon/daemon-client.ts b/packages/wallet-cli/src/daemon/daemon-client.ts new file mode 100644 index 00000000000..68ff9d39d39 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-client.ts @@ -0,0 +1,107 @@ +import type { JsonRpcResponse } from '@metamask/utils'; +import { assertIsJsonRpcResponse } from '@metamask/utils'; +import { randomUUID } from 'node:crypto'; +import { createConnection } from 'node:net'; +import type { Socket } from 'node:net'; + +import { readLine, writeLine } from './socket-line'; + +/** + * Options for {@link sendCommand}. + */ +type SendCommandOptions = { + /** The Unix socket path. */ + socketPath: string; + /** The RPC method name. */ + method: string; + /** Optional method parameters (object or positional array). */ + params?: Record | unknown[] | undefined; + /** Read timeout in milliseconds (default: no timeout). */ + timeoutMs?: number | undefined; +}; + +/** + * Connect to a Unix domain socket. + * + * @param socketPath - The socket path to connect to. + * @returns A connected socket. + */ +async function connectSocket(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath, () => { + socket.removeListener('error', reject); + resolve(socket); + }); + socket.on('error', reject); + }); +} + +/** + * Send a JSON-RPC request to the daemon over a Unix socket and return the + * response. + * + * Opens a connection, writes one JSON-RPC request line, reads one JSON-RPC + * response line, then closes the connection. Retries once after a short delay + * if the connection is rejected. + * + * @param options - Command options. + * @param options.socketPath - The Unix socket path. + * @param options.method - The RPC method name. + * @param options.params - Optional method parameters. + * @param options.timeoutMs - Read timeout in milliseconds. + * @returns The parsed JSON-RPC response. + */ +export async function sendCommand({ + socketPath, + method, + params, + timeoutMs, +}: SendCommandOptions): Promise { + const id = randomUUID(); + const request = { + jsonrpc: '2.0', + id, + method, + ...(params === undefined ? {} : { params }), + }; + + const attempt = async (): Promise => { + const socket = await connectSocket(socketPath); + try { + await writeLine(socket, JSON.stringify(request)); + const responseLine = await readLine(socket, timeoutMs); + const parsed: unknown = JSON.parse(responseLine); + assertIsJsonRpcResponse(parsed); + return parsed; + } finally { + socket.destroy(); + } + }; + + try { + return await attempt(); + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== 'ECONNREFUSED' && code !== 'ECONNRESET') { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + return attempt(); + } +} + +/** + * Check whether the daemon is running by sending a lightweight `getStatus` + * RPC call. + * + * @param socketPath - The Unix socket path. + * @returns True if the daemon responds to the RPC call. + */ +export async function pingDaemon(socketPath: string): Promise { + try { + await sendCommand({ socketPath, method: 'getStatus', timeoutMs: 3_000 }); + return true; + } catch { + return false; + } +} diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts new file mode 100644 index 00000000000..da3b0c8d505 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -0,0 +1,116 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { rm, writeFile } from 'node:fs/promises'; + +import { getDaemonPaths } from './paths'; +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcSocketServerHandle } from './rpc-socket-server'; +import type { RpcHandlerMap } from './types'; +import { createWallet } from './wallet-factory'; + +const startTime = Date.now(); + +main().catch((error: unknown) => { + process.stderr.write(`Daemon fatal: ${String(error)}\n`); + process.exitCode = 1; +}); + +/** + * Main daemon entry point. Starts the daemon process and keeps it running. + */ +async function main(): Promise { + const dataDir = process.env.MM_DAEMON_DATA_DIR; + if (!dataDir) { + throw new Error('MM_DAEMON_DATA_DIR environment variable is required'); + } + + const infuraProjectId = process.env.INFURA_PROJECT_ID; + if (!infuraProjectId) { + throw new Error('INFURA_PROJECT_ID environment variable is required'); + } + + mkdirSync(dataDir, { recursive: true }); + + const { + socketPath: defaultSocketPath, + pidPath, + logPath, + } = getDaemonPaths(dataDir); + const socketPath = process.env.MM_DAEMON_SOCKET_PATH ?? defaultSocketPath; + + const log = makeLogger(logPath); + log('Starting daemon...'); + + const wallet = createWallet({ infuraProjectId }); + + const handlers: RpcHandlerMap = { + getStatus: async () => ({ + pid: process.pid, + uptime: Math.floor((Date.now() - startTime) / 1000), + }), + }; + + let handle: RpcSocketServerHandle; + try { + await writeFile(pidPath, String(process.pid)); + + handle = await startRpcSocketServer({ + socketPath, + handlers, + onShutdown: async () => shutdown('RPC shutdown'), + }); + } catch (error) { + try { + await wallet.destroy(); + } catch { + // Best-effort cleanup. + } + rm(pidPath, { force: true }).catch(() => undefined); + throw error; + } + + log(`Daemon started. Socket: ${socketPath}`); + + let shutdownPromise: Promise | undefined; + + /** + * Shut down the daemon idempotently. Concurrent calls coalesce. + * + * @param reason - A label describing why shutdown was triggered. + * @returns A promise that resolves when shutdown completes. + */ + async function shutdown(reason: string): Promise { + if (shutdownPromise === undefined) { + log(`Shutting down (${reason})...`); + shutdownPromise = (async (): Promise => { + try { + await handle.close(); + await wallet.destroy(); + } finally { + rm(pidPath, { force: true }).catch(() => undefined); + rm(socketPath, { force: true }).catch(() => undefined); + } + })(); + } + return shutdownPromise; + } + + process.on('SIGTERM', () => { + shutdown('SIGTERM').catch(() => (process.exitCode = 1)); + }); + process.on('SIGINT', () => { + shutdown('SIGINT').catch(() => (process.exitCode = 1)); + }); +} + +/** + * Create a simple file logger. + * + * @param logPath - The log file path. + * @returns A logging function. + */ +function makeLogger(logPath: string): (message: string) => void { + return (message: string): void => { + const line = `[${new Date().toISOString()}] ${message}\n`; + appendFileSync(logPath, line); + }; +} diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts new file mode 100644 index 00000000000..c0228d00fc8 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -0,0 +1,78 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { pingDaemon } from './daemon-client'; +import type { DaemonSpawnConfig } from './types'; + +const POLL_INTERVAL_MS = 100; +const MAX_POLLS = 300; // 30 seconds + +/** + * Ensure the daemon is running. If it is not, spawn it as a detached process + * and wait until the socket becomes responsive. + * + * @param socketPath - The Unix socket path. + * @param config - Spawn configuration. + */ +export async function ensureDaemon( + socketPath: string, + config: DaemonSpawnConfig, +): Promise { + if (await pingDaemon(socketPath)) { + return; + } + + process.stderr.write('Starting daemon...\n'); + + const { entryPath, args } = resolveEntryPoint(config.packageRoot); + + const child = spawn(process.execPath, [...args, entryPath], { + detached: true, + stdio: 'ignore', + env: { + ...process.env, + MM_DAEMON_DATA_DIR: config.dataDir, + MM_DAEMON_SOCKET_PATH: socketPath, + INFURA_PROJECT_ID: config.infuraProjectId, + }, + }); + child.unref(); + + for (let i = 0; i < MAX_POLLS; i++) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + if (await pingDaemon(socketPath)) { + process.stderr.write('Daemon ready.\n'); + return; + } + } + + throw new Error( + `Daemon did not start within ${(MAX_POLLS * POLL_INTERVAL_MS) / 1000}s`, + ); +} + +/** + * Resolve the daemon entry point path and any extra Node.js args needed. + * + * In production, uses the compiled dist output. In development, uses tsx + * to run TypeScript source directly. + * + * @param packageRoot - The root directory of the wallet-cli package. + * @returns The entry path and any extra node args. + */ +function resolveEntryPoint(packageRoot: string): { + entryPath: string; + args: string[]; +} { + const distEntry = join(packageRoot, 'dist', 'daemon', 'daemon-entry.mjs'); + if (existsSync(distEntry)) { + return { entryPath: distEntry, args: [] }; + } + + const srcEntry = join(packageRoot, 'src', 'daemon', 'daemon-entry.ts'); + return { + entryPath: srcEntry, + args: ['--import', 'tsx'], + }; +} diff --git a/packages/wallet-cli/src/daemon/paths.ts b/packages/wallet-cli/src/daemon/paths.ts new file mode 100644 index 00000000000..950fadc2d39 --- /dev/null +++ b/packages/wallet-cli/src/daemon/paths.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path'; + +import type { DaemonPaths } from './types'; + +/** + * Resolve paths for daemon state files within the given data directory. + * + * @param dataDir - The base data directory (e.g. oclif config.dataDir). + * @returns Resolved paths for socket, PID file, and log file. + */ +export function getDaemonPaths(dataDir: string): DaemonPaths { + return { + socketPath: join(dataDir, 'daemon.sock'), + pidPath: join(dataDir, 'daemon.pid'), + logPath: join(dataDir, 'daemon.log'), + }; +} diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts new file mode 100644 index 00000000000..07428b7fd0c --- /dev/null +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -0,0 +1,205 @@ +import { unlink } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import type { Server } from 'node:net'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { hasProperty } from '@metamask/utils'; + +import type { RpcHandlerMap } from './types'; + +/** + * Handle returned by {@link startRpcSocketServer}. + */ +export type RpcSocketServerHandle = { + close: () => Promise; +}; + +/** + * Start a Unix socket server that processes JSON-RPC requests. + * + * Each connection reads one newline-delimited JSON-RPC request, processes it + * via the provided handler map, writes a JSON-RPC response, and closes. + * + * The special `shutdown` method is intercepted before handler dispatch and + * triggers the provided {@link onShutdown} callback after responding. + * + * @param options - Server options. + * @param options.socketPath - The Unix socket path to listen on. + * @param options.handlers - Map of RPC method names to handler functions. + * @param options.onShutdown - Callback invoked when a `shutdown` RPC is received. + * @returns A handle with a `close()` function for cleanup. + */ +export async function startRpcSocketServer({ + socketPath, + handlers, + onShutdown, +}: { + socketPath: string; + handlers: RpcHandlerMap; + onShutdown?: (() => Promise) | undefined; +}): Promise { + const server = createServer((socket) => { + let buffer = ''; + + const onData = (data: Buffer): void => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx === -1) { + return; + } + + // One request per connection. + socket.removeListener('data', onData); + + const line = buffer.slice(0, idx); + const remaining = buffer.slice(idx + 1); + buffer = ''; + + if (remaining.length > 0) { + socket.end( + `${JSON.stringify({ + jsonrpc: '2.0', + error: rpcErrors.invalidRequest({ + message: 'Only one request per connection is allowed', + }), + })}\n`, + ); + return; + } + + handleRequest(handlers, line, onShutdown) + .then((response) => { + socket.end(`${JSON.stringify(response)}\n`); + return undefined; + }) + .catch(() => { + socket.end( + `${JSON.stringify({ + jsonrpc: '2.0', + error: rpcErrors.internal({ message: 'Internal error' }), + })}\n`, + ); + }); + }; + socket.on('data', onData); + + socket.on('error', () => { + // Ignore client socket errors (e.g. broken pipe from probe connections). + }); + }); + + await listen(server, socketPath); + + return { + close: async (): Promise => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }, + }; +} + +/** + * Handle a single JSON-RPC request line, intercepting the `shutdown` method. + * + * @param handlers - The RPC handler map. + * @param line - The raw JSON line from the socket. + * @param onShutdown - Optional shutdown callback. + * @returns A JSON-RPC response object. + */ +async function handleRequest( + handlers: RpcHandlerMap, + line: string, + onShutdown?: () => Promise, +): Promise> { + let id: unknown = null; + + try { + const request = JSON.parse(line) as { + id?: unknown; + method?: string; + params?: unknown; + }; + id = request.id ?? null; + const { method } = request; + + if (typeof method !== 'string') { + return { + jsonrpc: '2.0', + id, + error: { code: -32600, message: 'Invalid request: missing method' }, + }; + } + + // Intercept shutdown before handler dispatch. + if (method === 'shutdown') { + if (onShutdown) { + setTimeout(() => { + onShutdown().catch(() => { + // Best-effort shutdown. + }); + }, 0); + } + return { jsonrpc: '2.0', id, result: { status: 'shutting down' } }; + } + + const handler = handlers[method]; + if (!handler) { + return { + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }; + } + + const params = (request.params as Parameters[0]) ?? null; + const result = await handler(params); + return { jsonrpc: '2.0', id, result: result ?? null }; + } catch (error) { + const code = isRpcError(error) ? error.code : -32603; + const message = error instanceof Error ? error.message : 'Internal error'; + return { jsonrpc: '2.0', id, error: { code, message } }; + } +} + +/** + * Check if an error is an RPC error with a numeric code. + * + * @param error - The error to check. + * @returns True if the error has a numeric code property. + */ +function isRpcError(error: unknown): error is { code: number } { + return ( + typeof error === 'object' && + error !== null && + hasProperty(error, 'code') && + typeof (error).code === 'number' + ); +} + +/** + * Start listening on a Unix socket path, removing any stale socket file. + * + * @param server - The net.Server instance. + * @param socketPath - The Unix socket path. + */ +async function listen(server: Server, socketPath: string): Promise { + try { + await unlink(socketPath); + } catch { + // Ignore — file may not exist. + } + + return new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(socketPath, () => { + server.removeListener('error', reject); + resolve(); + }); + }); +} diff --git a/packages/wallet-cli/src/daemon/socket-line.ts b/packages/wallet-cli/src/daemon/socket-line.ts new file mode 100644 index 00000000000..c061174fb83 --- /dev/null +++ b/packages/wallet-cli/src/daemon/socket-line.ts @@ -0,0 +1,86 @@ +import type { Socket } from 'node:net'; + +/** + * Write a newline-delimited line to a socket. + * + * @param socket - The socket to write to. + * @param line - The line to write (without trailing newline). + */ +export async function writeLine(socket: Socket, line: string): Promise { + return new Promise((resolve, reject) => { + socket.write(`${line}\n`, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +/** + * Read a single newline-delimited line from a socket. + * + * @param socket - The socket to read from. + * @param timeoutMs - Optional timeout in milliseconds. Rejects with a timeout + * error if no complete line is received within the limit. + * @returns The line read (without trailing newline). + */ +export async function readLine( + socket: Socket, + timeoutMs?: number, +): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + let timer: ReturnType | undefined; + + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + cleanup(); + reject(new Error('Socket read timed out')); + }, timeoutMs); + } + + const onData = (data: Buffer): void => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx !== -1) { + cleanup(); + resolve(buffer.slice(0, idx)); + } + }; + + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + + const onEnd = (): void => { + cleanup(); + reject(new Error('Socket closed before response received')); + }; + + const onClose = (): void => { + cleanup(); + reject(new Error('Socket closed before response received')); + }; + + /** + * Remove listeners registered by this call and clear the timeout. + */ + function cleanup(): void { + if (timer !== undefined) { + clearTimeout(timer); + } + socket.removeListener('data', onData); + socket.removeListener('error', onError); + socket.removeListener('end', onEnd); + socket.removeListener('close', onClose); + } + + socket.on('data', onData); + socket.once('error', onError); + socket.once('end', onEnd); + socket.once('close', onClose); + }); +} diff --git a/packages/wallet-cli/src/daemon/stop-daemon.ts b/packages/wallet-cli/src/daemon/stop-daemon.ts new file mode 100644 index 00000000000..e0b916281fa --- /dev/null +++ b/packages/wallet-cli/src/daemon/stop-daemon.ts @@ -0,0 +1,75 @@ +import { rm } from 'node:fs/promises'; + +import { pingDaemon, sendCommand } from './daemon-client'; +import { isProcessAlive, readPidFile, waitFor } from './utils'; + +/** + * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if + * the socket is unresponsive, and escalates to SIGKILL if SIGTERM is ignored. + * + * @param socketPath - The daemon socket path. + * @param pidPath - The daemon PID file path. + * @param log - Optional logging function for status messages. + * @returns True if the daemon was stopped (or was not running). + */ +export async function stopDaemon( + socketPath: string, + pidPath: string, + log?: (message: string) => void, +): Promise { + const pid = await readPidFile(pidPath); + const processAlive = pid !== undefined && isProcessAlive(pid); + const socketResponsive = await pingDaemon(socketPath); + + if (!socketResponsive && !processAlive) { + if (pid !== undefined) { + await rm(pidPath, { force: true }); + } + return true; + } + + log?.('Stopping daemon...'); + + let stopped = false; + + // Strategy 1: Graceful socket-based shutdown. + if (socketResponsive) { + try { + await sendCommand({ socketPath, method: 'shutdown' }); + } catch { + // Socket became unresponsive. + } + stopped = await waitFor(async () => !(await pingDaemon(socketPath)), 5_000); + } + + // Strategy 2: SIGTERM. + if (!stopped && pid !== undefined) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + stopped = true; + } + if (!stopped) { + stopped = await waitFor(() => !isProcessAlive(pid), 5_000); + } + } + + // Strategy 3: SIGKILL. + if (!stopped && pid !== undefined) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + stopped = true; + } + if (!stopped) { + stopped = await waitFor(() => !isProcessAlive(pid), 2_000); + } + } + + if (stopped) { + await rm(pidPath, { force: true }); + log?.('Daemon stopped.'); + } + + return stopped; +} diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts new file mode 100644 index 00000000000..d4506c7e888 --- /dev/null +++ b/packages/wallet-cli/src/daemon/types.ts @@ -0,0 +1,31 @@ +import type { Json } from '@metamask/utils'; + +/** + * A function that handles a JSON-RPC method call. + */ +export type RpcHandler = (params: Json) => Promise; + +/** + * A map of RPC method names to their handler functions. + */ +export type RpcHandlerMap = Record; + +/** + * Resolved paths for daemon state files. + */ +export type DaemonPaths = { + socketPath: string; + pidPath: string; + logPath: string; +}; + +/** + * Configuration passed to the daemon spawner. + */ +export type DaemonSpawnConfig = { + dataDir: string; + socketPath: string; + logPath: string; + infuraProjectId: string; + packageRoot: string; +}; diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts new file mode 100644 index 00000000000..bda14c1ea72 --- /dev/null +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -0,0 +1,120 @@ +import { hasProperty } from '@metamask/utils'; +import { readFile } from 'node:fs/promises'; + +/** + * Check whether an unknown error is a Node.js system error with the given code. + * + * @param error - The error to check. + * @param code - The expected error code (e.g. 'ENOENT', 'EPERM'). + * @returns True if the error matches the code. + */ +export function isErrorWithCode(error: unknown, code: string): boolean { + return ( + // TODO: use Error.isError() + error instanceof Error && + hasProperty(error, 'code') && + (error).code === code + ); +} + +/** + * Read a PID from a file. + * + * @param pidPath - The PID file path. + * @returns The PID, or undefined if the file is missing or invalid. + */ +export async function readPidFile( + pidPath: string, +): Promise { + try { + const pid = Number(await readFile(pidPath, 'utf-8')); + return pid > 0 && !Number.isNaN(pid) ? pid : undefined; + } catch (error: unknown) { + if (isErrorWithCode(error, 'ENOENT')) { + return undefined; + } + throw error; + } +} + +/** + * Check whether a process is alive by sending signal 0. + * + * @param pid - The process ID to check. + * @returns True if the process exists. + */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + if (isErrorWithCode(error, 'EPERM')) { + return true; + } + return false; + } +} + +/** + * Send a signal to a process. Returns true if the signal was sent, false if + * the process does not exist (ESRCH). Re-throws on permission errors and + * other failures. + * + * @param pid - The process ID. + * @param signal - The signal to send. + * @returns True if the signal was delivered, false if the process is gone. + */ +export function sendSignal(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch (error: unknown) { + if (isErrorWithCode(error, 'ESRCH')) { + return false; + } + throw error; + } +} + +/** + * Poll until a condition is met or the timeout elapses. + * + * @param check - A function that returns true when the condition is met. + * @param timeoutMs - Maximum time to wait in milliseconds. + * @returns True if the condition was met, false on timeout. + */ +export async function waitFor( + check: () => boolean | Promise, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return await check(); +} + +/** + * Wrap a promise with a timeout rejection. + * + * @param promise - The promise to wrap with a timeout. + * @param timeout - How many ms to wait before rejecting. + * @returns A wrapped promise which rejects after timeout milliseconds. + */ +export async function withTimeout( + promise: Promise, + timeout: number, +): Promise { + return Promise.race([ + promise, + new Promise((_resolve, reject) => + setTimeout( + () => reject(new Error(`promise timed out after ${timeout}ms`)), + timeout, + ), + ), + ]) as Promise; +} diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts new file mode 100644 index 00000000000..8a975e03a6c --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -0,0 +1,38 @@ +import { + ClientConfigApiService, + ClientType, + DistributionType, + EnvironmentType, +} from '@metamask/remote-feature-flag-controller'; +import { Wallet } from '@metamask/wallet'; + +/** + * Create a configured Wallet instance for daemon use. + * + * @param config - Wallet configuration. + * @param config.infuraProjectId - The Infura project ID for network access. + * @returns A new Wallet instance. + */ +export function createWallet({ + infuraProjectId, +}: { + infuraProjectId: string; +}): Wallet { + return new Wallet({ + options: { + infuraProjectId, + clientVersion: '0.0.0', + // TODO: Implement showApprovalRequest + showApprovalRequest: () => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: () => 'cli', + }, + }); +} diff --git a/packages/wallet-cli/tsconfig.build.json b/packages/wallet-cli/tsconfig.build.json index 02a0eea03fe..ac3df52090a 100644 --- a/packages/wallet-cli/tsconfig.build.json +++ b/packages/wallet-cli/tsconfig.build.json @@ -5,6 +5,13 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, + { + "path": "../wallet/tsconfig.build.json" + } + ], "include": ["../../types", "./src"] } diff --git a/packages/wallet-cli/tsconfig.json b/packages/wallet-cli/tsconfig.json index 025ba2ef7f4..7eb92377c96 100644 --- a/packages/wallet-cli/tsconfig.json +++ b/packages/wallet-cli/tsconfig.json @@ -3,6 +3,13 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], - "include": ["../../types", "./src"] + "references": [ + { + "path": "../remote-feature-flag-controller/tsconfig.json" + }, + { + "path": "../wallet/tsconfig.json" + } + ], + "include": ["../../types", "./bin", "./src"] } diff --git a/yarn.lock b/yarn.lock index c023a835650..3adc538f907 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5832,6 +5832,10 @@ __metadata: resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/remote-feature-flag-controller": "npm:^4.2.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.9.0" + "@metamask/wallet": "npm:^0.0.0" "@oclif/core": "npm:^4.10.5" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -5847,7 +5851,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/wallet@workspace:packages/wallet": +"@metamask/wallet@npm:^0.0.0, @metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: From 74df0ee4d0a5500c284951542283f99d92d99970 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:12:53 -0700 Subject: [PATCH 04/32] test(wallet-cli): Add unit tests for daemon modules with 100% coverage 9 test files covering all daemon infrastructure: paths, socket-line, utils, rpc-socket-server, daemon-client, stop-daemon, wallet-factory, daemon-entry, and daemon-spawn. 96 tests total. Config changes: - jest.config.js: exclude commands/ from coverage (not yet tested) - eslint.config.mjs: disable n/no-process-env and n/no-sync for wallet-cli test files Co-Authored-By: Claude Opus 4.6 (1M context) --- eslint.config.mjs | 7 + packages/wallet-cli/jest.config.js | 3 + .../src/daemon/daemon-client.test.ts | 201 +++++++ .../src/daemon/daemon-entry.test.ts | 352 ++++++++++++ .../src/daemon/daemon-spawn.test.ts | 127 +++++ packages/wallet-cli/src/daemon/paths.test.ts | 16 + .../src/daemon/rpc-socket-server.test.ts | 505 ++++++++++++++++++ .../src/daemon/rpc-socket-server.ts | 6 +- .../wallet-cli/src/daemon/socket-line.test.ts | 120 +++++ .../wallet-cli/src/daemon/stop-daemon.test.ts | 187 +++++++ packages/wallet-cli/src/daemon/utils.test.ts | 182 +++++++ packages/wallet-cli/src/daemon/utils.ts | 4 +- .../src/daemon/wallet-factory.test.ts | 28 + 13 files changed, 1732 insertions(+), 6 deletions(-) create mode 100644 packages/wallet-cli/src/daemon/daemon-client.test.ts create mode 100644 packages/wallet-cli/src/daemon/daemon-entry.test.ts create mode 100644 packages/wallet-cli/src/daemon/daemon-spawn.test.ts create mode 100644 packages/wallet-cli/src/daemon/paths.test.ts create mode 100644 packages/wallet-cli/src/daemon/rpc-socket-server.test.ts create mode 100644 packages/wallet-cli/src/daemon/socket-line.test.ts create mode 100644 packages/wallet-cli/src/daemon/stop-daemon.test.ts create mode 100644 packages/wallet-cli/src/daemon/utils.test.ts create mode 100644 packages/wallet-cli/src/daemon/wallet-factory.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 82abb621748..136b01ee69d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -307,6 +307,13 @@ const config = createConfig([ 'no-restricted-globals': 'off', }, }, + { + files: ['packages/wallet-cli/src/**/*.test.{js,ts}'], + rules: { + 'n/no-process-env': 'off', + 'n/no-sync': 'off', + }, + }, { files: ['packages/wallet-cli/bin/**/*.mjs'], rules: { diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js index ca084133399..e863064fbc6 100644 --- a/packages/wallet-cli/jest.config.js +++ b/packages/wallet-cli/jest.config.js @@ -14,6 +14,9 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // TODO: Add tests for commands + coveragePathIgnorePatterns: ['.*/commands/.*'], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/wallet-cli/src/daemon/daemon-client.test.ts b/packages/wallet-cli/src/daemon/daemon-client.test.ts new file mode 100644 index 00000000000..a3cf69f717b --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-client.test.ts @@ -0,0 +1,201 @@ +import type { JsonRpcResponse } from '@metamask/utils'; +import { EventEmitter } from 'node:events'; +import { createConnection } from 'node:net'; +import type { Socket } from 'node:net'; + +import { sendCommand, pingDaemon } from './daemon-client'; +import { readLine, writeLine } from './socket-line'; + +jest.mock('node:net'); +jest.mock('./socket-line'); + +const mockCreateConnection = jest.mocked(createConnection); +const mockReadLine = jest.mocked(readLine); +const mockWriteLine = jest.mocked(writeLine); + +/** + * Create a mock Socket and wire up createConnection to return it. + * The connection callback is deferred via process.nextTick to match + * real behavior (the `socket` const must be assigned before the callback + * references it). + * + * @returns The mock socket. + */ +function setupMockSocket(): Socket { + const emitter = new EventEmitter(); + const socket = Object.assign(emitter, { + destroy: jest.fn(), + write: jest.fn(), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Socket; + + mockCreateConnection.mockImplementation( + (_path: unknown, callback: unknown) => { + process.nextTick(() => (callback as () => void)()); + return socket; + }, + ); + + return socket; +} + +const VALID_RESPONSE: JsonRpcResponse = { + jsonrpc: '2.0', + id: 'test-id', + result: { status: 'ok' }, +}; + +describe('sendCommand', () => { + it('sends a JSON-RPC request and returns the response', async () => { + const socket = setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + const response = await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'getStatus', + }); + + expect(mockCreateConnection).toHaveBeenCalledWith( + '/tmp/test.sock', + expect.any(Function), + ); + expect(mockWriteLine).toHaveBeenCalledWith( + socket, + expect.stringContaining('"method":"getStatus"'), + ); + expect(response.result).toStrictEqual({ status: 'ok' }); + expect(socket.destroy).toHaveBeenCalled(); + }); + + it('includes params when provided', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + params: { key: 'value' }, + }); + + const written = mockWriteLine.mock.calls[0][1]; + expect(JSON.parse(written)).toHaveProperty('params', { key: 'value' }); + }); + + it('omits params when undefined', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + }); + + const written = mockWriteLine.mock.calls[0][1]; + expect(JSON.parse(written)).not.toHaveProperty('params'); + }); + + it('passes timeoutMs to readLine', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + timeoutMs: 5000, + }); + + expect(mockReadLine).toHaveBeenCalledWith(expect.anything(), 5000); + }); + + it('retries once on ECONNREFUSED', async () => { + const socket = setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine + .mockRejectedValueOnce( + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ) + .mockResolvedValueOnce(JSON.stringify(VALID_RESPONSE)); + + const response = await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + }); + + expect(response.result).toStrictEqual({ status: 'ok' }); + expect(socket.destroy).toHaveBeenCalledTimes(2); + }); + + it('retries once on ECONNRESET', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine + .mockRejectedValueOnce( + Object.assign(new Error('reset'), { code: 'ECONNRESET' }), + ) + .mockResolvedValueOnce(JSON.stringify(VALID_RESPONSE)); + + const response = await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + }); + + expect(response).toHaveProperty('result'); + }); + + it('does not retry on other errors', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockRejectedValue(new Error('parse error')); + + await expect( + sendCommand({ socketPath: '/tmp/test.sock', method: 'test' }), + ).rejects.toThrow('parse error'); + + expect(mockReadLine).toHaveBeenCalledTimes(1); + }); + + it('destroys socket even when attempt throws', async () => { + const socket = setupMockSocket(); + mockWriteLine.mockRejectedValue(new Error('write error')); + + await expect( + sendCommand({ socketPath: '/tmp/test.sock', method: 'test' }), + ).rejects.toThrow('write error'); + + expect(socket.destroy).toHaveBeenCalled(); + }); +}); + +describe('pingDaemon', () => { + it('returns true when daemon responds', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + expect(await pingDaemon('/tmp/test.sock')).toBe(true); + }); + + it('returns false when daemon is unresponsive', async () => { + mockCreateConnection.mockImplementation((_path: unknown) => { + const emitter = new EventEmitter(); + const socket = Object.assign(emitter, { + destroy: jest.fn(), + write: jest.fn(), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Socket; + process.nextTick(() => + socket.emit( + 'error', + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ), + ); + return socket; + }); + + expect(await pingDaemon('/tmp/test.sock')).toBe(false); + }); +}); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts new file mode 100644 index 00000000000..64d4fd5937f --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -0,0 +1,352 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { rm, writeFile } from 'node:fs/promises'; + +import { getDaemonPaths } from './paths'; +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcSocketServerHandle } from './rpc-socket-server'; +import { createWallet } from './wallet-factory'; + +jest.mock('node:fs'); +jest.mock('node:fs/promises'); +jest.mock('./paths'); +jest.mock('./rpc-socket-server'); +jest.mock('./wallet-factory'); + +const mockMkdirSync = jest.mocked(mkdirSync); +const mockAppendFileSync = jest.mocked(appendFileSync); +const mockWriteFile = jest.mocked(writeFile); +const mockRm = jest.mocked(rm); +const mockGetDaemonPaths = jest.mocked(getDaemonPaths); +const mockStartRpcSocketServer = jest.mocked(startRpcSocketServer); +const mockCreateWallet = jest.mocked(createWallet); + +// The module under test calls main() at top level on import. +// We use jest.isolateModules to re-import it fresh in each test +// after setting up mocks and env vars. + +const ORIGINAL_ENV = process.env; + +/** + * Create a mock wallet. + * + * @returns A mock wallet object. + */ +function createMockWallet(): ReturnType { + return { + messenger: {} as never, + state: {} as never, + destroy: jest.fn().mockResolvedValue(undefined), + } as unknown as ReturnType; +} + +/** + * Create a mock server handle. + * + * @returns A mock server handle. + */ +function createMockHandle(): RpcSocketServerHandle { + return { close: jest.fn().mockResolvedValue(undefined) }; +} + +describe('daemon-entry', () => { + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + process.exitCode = undefined; + stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + mockGetDaemonPaths.mockReturnValue({ + socketPath: '/tmp/daemon.sock', + pidPath: '/tmp/daemon.pid', + logPath: '/tmp/daemon.log', + }); + mockWriteFile.mockResolvedValue(undefined); + mockRm.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + process.exitCode = undefined; + }); + + /** + * Import daemon-entry in an isolated module scope so its top-level + * main() runs with the current mocks and env vars. + * Returns after main() settles. + */ + async function importDaemonEntry(): Promise { + await jest.isolateModulesAsync(async () => { + await import('./daemon-entry'); + // Flush microtasks so main()'s .catch() handler settles + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + }); + } + + it('writes to stderr and sets exitCode when MM_DAEMON_DATA_DIR is missing', async () => { + delete process.env.MM_DAEMON_DATA_DIR; + process.env.INFURA_PROJECT_ID = 'key'; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_DAEMON_DATA_DIR'), + ); + expect(process.exitCode).toBe(1); + }); + + it('writes to stderr and sets exitCode when INFURA_PROJECT_ID is missing', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + delete process.env.INFURA_PROJECT_ID; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('INFURA_PROJECT_ID'), + ); + expect(process.exitCode).toBe(1); + }); + + it('creates data dir, wallet, server, and writes PID on successful startup', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/data', { + recursive: true, + }); + expect(mockCreateWallet).toHaveBeenCalledWith({ infuraProjectId: 'key' }); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/daemon.pid', + String(process.pid), + ); + expect(mockStartRpcSocketServer).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: '/tmp/daemon.sock', + }), + ); + expect(process.exitCode).toBeUndefined(); + }); + + it('uses MM_DAEMON_SOCKET_PATH override when set', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + process.env.MM_DAEMON_SOCKET_PATH = '/custom/sock'; + + mockCreateWallet.mockReturnValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockStartRpcSocketServer).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: '/custom/sock', + }), + ); + }); + + it('cleans up wallet and PID file when server fails to start', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + + await importDaemonEntry(); + + expect(wallet.destroy).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(process.exitCode).toBe(1); + }); + + it('still cleans up PID when wallet.destroy fails during error cleanup', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + (wallet.destroy as jest.Mock).mockRejectedValue( + new Error('destroy failed'), + ); + mockCreateWallet.mockReturnValue(wallet); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + + await importDaemonEntry(); + + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(process.exitCode).toBe(1); + }); + + it('exposes getStatus handler that returns pid and uptime', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + mockCreateWallet.mockReturnValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // Extract the handlers passed to startRpcSocketServer + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const { handlers } = callArgs; + const status = (await handlers.getStatus(null)) as { + pid: number; + uptime: number; + }; + + expect(status.pid).toBe(process.pid); + expect(typeof status.uptime).toBe('number'); + }); + + it('logs to file via makeLogger', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + mockCreateWallet.mockReturnValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // makeLogger writes via appendFileSync to the log path + expect(mockAppendFileSync).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('Starting daemon...'), + ); + }); + + it('registers SIGTERM and SIGINT handlers', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + mockCreateWallet.mockReturnValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + const registeredEvents = onSpy.mock.calls.map(([event]) => event); + expect(registeredEvents).toContain('SIGTERM'); + expect(registeredEvents).toContain('SIGINT'); + }); + + it('sIGTERM handler calls shutdown and sets exitCode on failure', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + const handle = createMockHandle(); + (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); + mockStartRpcSocketServer.mockResolvedValue(handle); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + // Find the SIGTERM handler and invoke it + const sigTermCall = onSpy.mock.calls.find(([event]) => event === 'SIGTERM'); + const sigTermHandler = sigTermCall?.[1] as () => void; + sigTermHandler(); + + // Flush promise chain + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(process.exitCode).toBe(1); + }); + + it('sIGINT handler calls shutdown and sets exitCode on failure', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + const handle = createMockHandle(); + (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); + mockStartRpcSocketServer.mockResolvedValue(handle); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + const sigIntCall = onSpy.mock.calls.find(([event]) => event === 'SIGINT'); + const sigIntHandler = sigIntCall?.[1] as () => void; + sigIntHandler(); + + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(process.exitCode).toBe(1); + }); + + it('handles rm rejection during shutdown cleanup gracefully', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + // rm rejects but cleanup should not fail + mockRm.mockRejectedValue(new Error('rm failed')); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + + await onShutdown(); + + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); + }); + + it('handles rm rejection in error cleanup path gracefully', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + mockRm.mockRejectedValue(new Error('rm failed')); + + await importDaemonEntry(); + + expect(process.exitCode).toBe(1); + }); + + it('onShutdown closes server and destroys wallet', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + // Extract the onShutdown callback + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + + await onShutdown(); + + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts new file mode 100644 index 00000000000..bac8ecfe66c --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -0,0 +1,127 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +import { pingDaemon } from './daemon-client'; +import { ensureDaemon } from './daemon-spawn'; +import type { DaemonSpawnConfig } from './types'; + +jest.mock('node:child_process'); +jest.mock('node:fs'); +jest.mock('./daemon-client'); + +const mockSpawn = jest.mocked(spawn); +const mockExistsSync = jest.mocked(existsSync); +const mockPingDaemon = jest.mocked(pingDaemon); + +const CONFIG: DaemonSpawnConfig = { + dataDir: '/tmp/data', + socketPath: '/tmp/test.sock', + logPath: '/tmp/daemon.log', + infuraProjectId: 'test-key', + packageRoot: '/pkg', +}; + +describe('ensureDaemon', () => { + beforeEach(() => { + jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockSpawn.mockReturnValue({ + unref: jest.fn(), + } as never); + }); + + it('returns immediately if daemon is already running', async () => { + mockPingDaemon.mockResolvedValue(true); + + await ensureDaemon('/tmp/test.sock', CONFIG); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('spawns daemon as detached child with correct env vars', async () => { + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + + await ensureDaemon('/tmp/test.sock', CONFIG); + + expect(mockSpawn).toHaveBeenCalledWith( + process.execPath, + ['/pkg/dist/daemon/daemon-entry.mjs'], + expect.objectContaining({ + detached: true, + stdio: 'ignore', + env: expect.objectContaining({ + MM_DAEMON_DATA_DIR: '/tmp/data', + MM_DAEMON_SOCKET_PATH: '/tmp/test.sock', + INFURA_PROJECT_ID: 'test-key', + }), + }), + ); + }); + + it('uses dist entry when it exists', async () => { + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + + await ensureDaemon('/tmp/test.sock', CONFIG); + + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toStrictEqual(['/pkg/dist/daemon/daemon-entry.mjs']); + }); + + it('falls back to src entry with tsx when dist missing', async () => { + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(false); + + await ensureDaemon('/tmp/test.sock', CONFIG); + + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toStrictEqual([ + '--import', + 'tsx', + '/pkg/src/daemon/daemon-entry.ts', + ]); + }); + + it('polls until daemon is ready', async () => { + mockPingDaemon + .mockResolvedValueOnce(false) // initial check + .mockResolvedValueOnce(false) // poll 1 + .mockResolvedValueOnce(false) // poll 2 + .mockResolvedValueOnce(true); // poll 3 + mockExistsSync.mockReturnValue(true); + + await ensureDaemon('/tmp/test.sock', CONFIG); + + expect(mockPingDaemon).toHaveBeenCalledTimes(4); + expect(process.stderr.write).toHaveBeenCalledWith('Daemon ready.\n'); + }); + + it('throws after timeout when daemon never responds', async () => { + jest.useFakeTimers(); + mockPingDaemon.mockResolvedValue(false); + mockExistsSync.mockReturnValue(true); + + const promise = ensureDaemon('/tmp/test.sock', CONFIG); + // Attach rejection handler before advancing timers to avoid unhandled rejection + const rejection = promise.catch((thrown: unknown) => thrown); + + // Advance past all 300 polls (100ms each = 30s) + await jest.advanceTimersByTimeAsync(30_100); + const thrownError = await rejection; + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe( + 'Daemon did not start within 30s', + ); + jest.useRealTimers(); + }); + + it('calls unref on spawned child', async () => { + const unref = jest.fn(); + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + mockSpawn.mockReturnValue({ unref } as never); + + await ensureDaemon('/tmp/test.sock', CONFIG); + + expect(unref).toHaveBeenCalled(); + }); +}); diff --git a/packages/wallet-cli/src/daemon/paths.test.ts b/packages/wallet-cli/src/daemon/paths.test.ts new file mode 100644 index 00000000000..5ea1f81f092 --- /dev/null +++ b/packages/wallet-cli/src/daemon/paths.test.ts @@ -0,0 +1,16 @@ +import { join } from 'node:path'; + +import { getDaemonPaths } from './paths'; + +describe('getDaemonPaths', () => { + it('returns correct paths for the given data directory', () => { + const dataDir = '/tmp/test-data'; + const paths = getDaemonPaths(dataDir); + + expect(paths).toStrictEqual({ + socketPath: join(dataDir, 'daemon.sock'), + pidPath: join(dataDir, 'daemon.pid'), + logPath: join(dataDir, 'daemon.log'), + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts new file mode 100644 index 00000000000..10446030dea --- /dev/null +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -0,0 +1,505 @@ +import { EventEmitter } from 'node:events'; +import { unlink } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import type { Server, Socket } from 'node:net'; + +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcHandlerMap } from './types'; + +jest.mock('node:fs/promises'); +jest.mock('node:net'); + +const mockUnlink = jest.mocked(unlink); +const mockCreateServer = jest.mocked(createServer); + +type ConnectionCallback = (socket: Socket) => void; + +/** + * Flush pending microtasks/promises by awaiting multiple ticks. + */ +async function flushPromises(): Promise { + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } +} + +/** + * Create a mock net.Server. + * + * @returns The mock server and a function to simulate incoming connections. + */ +function createMockServer(): { + server: Server; + simulateConnection: (socket: Socket) => void; +} { + const emitter = new EventEmitter(); + let connectionCallback: ConnectionCallback | undefined; + + const server = Object.assign(emitter, { + listen: jest.fn((_path: string, onListening: () => void) => { + onListening(); + }), + close: jest.fn((onClose: (closeError?: Error) => void) => { + onClose(); + }), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Server; + + mockCreateServer.mockImplementation((handler: unknown) => { + connectionCallback = handler as ConnectionCallback; + return server; + }); + + return { + server, + simulateConnection: (socket: Socket): void => { + connectionCallback?.(socket); + }, + }; +} + +/** + * Create a mock Socket. + * + * @returns A mock socket. + */ +function createMockSocket(): Socket { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + end: jest.fn(), + destroy: jest.fn(), + write: jest.fn(), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Socket; +} + +/** + * Parse the JSON-RPC response written to socket.end(). + * + * @param socket - The mock socket. + * @returns The parsed response. + */ +function getResponse(socket: Socket): Record { + const endCall = (socket.end as jest.Mock).mock.calls[0][0] as string; + return JSON.parse(endCall.trim()) as Record; +} + +/** + * Send a JSON-RPC request to a mock socket by emitting data. + * + * @param socket - The mock socket. + * @param request - The request object. + */ +function sendRequest(socket: Socket, request: Record): void { + socket.emit('data', Buffer.from(`${JSON.stringify(request)}\n`)); +} + +describe('startRpcSocketServer', () => { + beforeEach(() => { + mockUnlink.mockResolvedValue(undefined); + }); + + it('removes stale socket file before listening', async () => { + createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + expect(mockUnlink).toHaveBeenCalledWith('/tmp/test.sock'); + }); + + it('ignores unlink errors for missing files', async () => { + mockUnlink.mockRejectedValue(new Error('ENOENT')); + createMockServer(); + + const handle = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + expect(handle).toBeDefined(); + }); + + it('returns a handle with close()', async () => { + const { server } = createMockServer(); + const handle = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + await handle.close(); + expect(server.close).toHaveBeenCalled(); + }); + + it('rejects close() when server.close errors', async () => { + createMockServer(); + const handle = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const { server } = createMockServer(); + (server.close as jest.Mock).mockImplementation( + (onClose: (closeError?: Error) => void) => { + onClose(new Error('close failed')); + }, + ); + + const handle2 = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + await expect(handle2.close()).rejects.toThrow('close failed'); + await handle.close(); + }); + + describe('request handling', () => { + it('dispatches valid request to handler and returns result', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + getStatus: jest.fn().mockResolvedValue({ status: 'ok' }), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { + jsonrpc: '2.0', + id: '1', + method: 'getStatus', + }); + + await flushPromises(); + + expect(getResponse(socket)).toStrictEqual({ + jsonrpc: '2.0', + id: '1', + result: { status: 'ok' }, + }); + }); + + it('returns null result when handler returns undefined', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + noop: jest.fn().mockResolvedValue(undefined), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'noop' }); + + await flushPromises(); + + expect(getResponse(socket).result).toBeNull(); + }); + + it('returns -32600 for missing method', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32600, + message: 'Invalid request: missing method', + }); + }); + + it('returns -32601 for unknown method', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { + jsonrpc: '2.0', + id: '1', + method: 'nonexistent', + }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32601, + message: 'Method not found: nonexistent', + }); + }); + + it('returns -32603 when handler throws an Error', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest.fn().mockRejectedValue(new Error('handler failed')), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32603, + message: 'handler failed', + }); + }); + + it('uses error code when handler throws an RPC error', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest + .fn() + .mockRejectedValue( + Object.assign(new Error('custom rpc'), { code: -32001 }), + ), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32001, + message: 'custom rpc', + }); + }); + + it('returns Internal error when handler throws a non-Error value', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest.fn().mockRejectedValue('string error'), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32603, + message: 'Internal error', + }); + }); + + it('intercepts shutdown method and calls onShutdown', async () => { + jest.useFakeTimers(); + const { simulateConnection } = createMockServer(); + const onShutdown = jest.fn().mockResolvedValue(undefined); + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + onShutdown, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'shutdown' }); + + await jest.advanceTimersByTimeAsync(0); + + expect(getResponse(socket).result).toStrictEqual({ + status: 'shutting down', + }); + expect(onShutdown).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('handles onShutdown rejection gracefully', async () => { + jest.useFakeTimers(); + const { simulateConnection } = createMockServer(); + const onShutdown = jest.fn().mockRejectedValue(new Error('shutdown err')); + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + onShutdown, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'shutdown' }); + + await jest.advanceTimersByTimeAsync(0); + + expect(getResponse(socket).result).toStrictEqual({ + status: 'shutting down', + }); + jest.useRealTimers(); + }); + + it('responds to shutdown even without onShutdown callback', async () => { + const { simulateConnection } = createMockServer(); + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'shutdown' }); + + await flushPromises(); + + expect(getResponse(socket).result).toStrictEqual({ + status: 'shutting down', + }); + }); + + it('rejects multiple requests per connection', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + socket.emit( + 'data', + Buffer.from( + `${JSON.stringify({ jsonrpc: '2.0', id: '1', method: 'a' })}\nextra`, + ), + ); + + const endCall = (socket.end as jest.Mock).mock.calls[0][0] as string; + const response = JSON.parse(endCall.trim()) as Record; + expect(response.error).toBeDefined(); + }); + + it('accumulates partial data across multiple events', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + test: jest.fn().mockResolvedValue('ok'), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + const full = JSON.stringify({ + jsonrpc: '2.0', + id: '1', + method: 'test', + }); + socket.emit('data', Buffer.from(full.slice(0, 10))); + socket.emit('data', Buffer.from(`${full.slice(10)}\n`)); + + await flushPromises(); + + expect(getResponse(socket).result).toBe('ok'); + }); + + it('ignores socket errors', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + // Should not throw + expect(() => + socket.emit('error', new Error('broken pipe')), + ).not.toThrow(); + }); + + it('sends internal error when response serialization fails', async () => { + const { simulateConnection } = createMockServer(); + const circular: Record = {}; + circular.self = circular; + const handlers: RpcHandlerMap = { + bad: jest.fn().mockResolvedValue(circular), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'bad' }); + + await flushPromises(); + + const endCall = (socket.end as jest.Mock).mock.calls[0][0] as string; + const response = JSON.parse(endCall.trim()) as Record; + expect(response.error).toBeDefined(); + }); + + it('handles invalid JSON gracefully', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + socket.emit('data', Buffer.from('not-json\n')); + + await flushPromises(); + + expect((getResponse(socket).error as { code: number }).code).toBe(-32603); + }); + + it('uses null id when request has no id', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', method: 'unknown' }); + + await flushPromises(); + + expect(getResponse(socket).id).toBeNull(); + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 07428b7fd0c..0fdbdbb319d 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -1,8 +1,8 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import { hasProperty } from '@metamask/utils'; import { unlink } from 'node:fs/promises'; import { createServer } from 'node:net'; import type { Server } from 'node:net'; -import { rpcErrors } from '@metamask/rpc-errors'; -import { hasProperty } from '@metamask/utils'; import type { RpcHandlerMap } from './types'; @@ -178,7 +178,7 @@ function isRpcError(error: unknown): error is { code: number } { typeof error === 'object' && error !== null && hasProperty(error, 'code') && - typeof (error).code === 'number' + typeof error.code === 'number' ); } diff --git a/packages/wallet-cli/src/daemon/socket-line.test.ts b/packages/wallet-cli/src/daemon/socket-line.test.ts new file mode 100644 index 00000000000..b91e4b97245 --- /dev/null +++ b/packages/wallet-cli/src/daemon/socket-line.test.ts @@ -0,0 +1,120 @@ +import { EventEmitter } from 'node:events'; +import type { Socket } from 'node:net'; + +import { readLine, writeLine } from './socket-line'; + +/** + * Create a mock Socket backed by EventEmitter. + * + * @returns A mock socket. + */ +function createMockSocket(): Socket { + const emitter = new EventEmitter(); + const socket = Object.assign(emitter, { + write: jest.fn(), + destroy: jest.fn(), + }); + return socket as unknown as Socket; +} + +describe('writeLine', () => { + it('writes the line with a trailing newline', async () => { + const socket = createMockSocket(); + (socket.write as jest.Mock).mockImplementation( + (_data: string, callback: (writeError?: Error) => void) => callback(), + ); + + await writeLine(socket, 'hello'); + expect(socket.write).toHaveBeenCalledWith('hello\n', expect.any(Function)); + }); + + it('rejects when socket.write returns an error', async () => { + const socket = createMockSocket(); + const writeError = new Error('write failed'); + (socket.write as jest.Mock).mockImplementation( + (_data: string, callback: (e?: Error) => void) => callback(writeError), + ); + + await expect(writeLine(socket, 'hello')).rejects.toThrow('write failed'); + }); +}); + +describe('readLine', () => { + it('resolves with the line when data contains a newline', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('data', Buffer.from('hello\n')); + expect(await promise).toBe('hello'); + }); + + it('accumulates data across multiple events', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('data', Buffer.from('hel')); + socket.emit('data', Buffer.from('lo\n')); + expect(await promise).toBe('hello'); + }); + + it('rejects on socket error', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('error', new Error('socket error')); + await expect(promise).rejects.toThrow('socket error'); + }); + + it('rejects on socket end', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('end'); + await expect(promise).rejects.toThrow( + 'Socket closed before response received', + ); + }); + + it('rejects on socket close', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('close'); + await expect(promise).rejects.toThrow( + 'Socket closed before response received', + ); + }); + + it('rejects after timeout when no complete line received', async () => { + jest.useFakeTimers(); + const socket = createMockSocket(); + const promise = readLine(socket, 500); + + jest.advanceTimersByTime(500); + await expect(promise).rejects.toThrow('Socket read timed out'); + jest.useRealTimers(); + }); + + it('resolves before timeout when data arrives in time', async () => { + jest.useFakeTimers(); + const socket = createMockSocket(); + const promise = readLine(socket, 5000); + + socket.emit('data', Buffer.from('hello\n')); + expect(await promise).toBe('hello'); + jest.useRealTimers(); + }); + + it('cleans up listeners after resolving', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('data', Buffer.from('hello\n')); + await promise; + + expect(socket.listenerCount('data')).toBe(0); + expect(socket.listenerCount('error')).toBe(0); + expect(socket.listenerCount('end')).toBe(0); + expect(socket.listenerCount('close')).toBe(0); + }); +}); diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts new file mode 100644 index 00000000000..3bb6e0a8eea --- /dev/null +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -0,0 +1,187 @@ +import { rm } from 'node:fs/promises'; + +import { pingDaemon, sendCommand } from './daemon-client'; +import { stopDaemon } from './stop-daemon'; +import { isProcessAlive, readPidFile, waitFor } from './utils'; + +jest.mock('node:fs/promises'); +jest.mock('./daemon-client'); +jest.mock('./utils'); + +const mockRm = jest.mocked(rm); +const mockPingDaemon = jest.mocked(pingDaemon); +const mockSendCommand = jest.mocked(sendCommand); +const mockReadPidFile = jest.mocked(readPidFile); +const mockIsProcessAlive = jest.mocked(isProcessAlive); +const mockWaitFor = jest.mocked(waitFor); + +describe('stopDaemon', () => { + beforeEach(() => { + mockRm.mockResolvedValue(undefined); + }); + + it('returns true when daemon is not running (no PID file)', async () => { + mockReadPidFile.mockResolvedValue(undefined); + mockPingDaemon.mockResolvedValue(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); + + it('cleans up stale PID file when daemon is not running', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(false); + mockPingDaemon.mockResolvedValue(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockRm).toHaveBeenCalledWith('/tmp/test.pid', { force: true }); + }); + + it('stops daemon via graceful RPC shutdown', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { status: 'shutting down' }, + }); + // Invoke the check callback for coverage, then return true + mockWaitFor.mockImplementation(async (check) => { + await check(); + return true; + }); + + const log = jest.fn(); + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid', log); + + expect(result).toBe(true); + expect(mockSendCommand).toHaveBeenCalledWith({ + socketPath: '/tmp/test.sock', + method: 'shutdown', + }); + expect(log).toHaveBeenCalledWith('Stopping daemon...'); + expect(log).toHaveBeenCalledWith('Daemon stopped.'); + }); + + it('falls through to SIGTERM when graceful shutdown times out', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + // First waitFor (graceful) invokes cb and fails, second (SIGTERM) invokes cb and succeeds + mockWaitFor + .mockImplementationOnce(async (check) => { + await check(); + return false; + }) + .mockImplementationOnce(async (check) => { + await check(); + return true; + }); + jest.spyOn(process, 'kill').mockImplementation(() => true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(process.kill).toHaveBeenCalledWith(123, 'SIGTERM'); + }); + + it('falls through to SIGKILL when SIGTERM times out', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + // All three waitFor calls invoke check, graceful + SIGTERM fail, SIGKILL succeeds + mockWaitFor + .mockImplementationOnce(async (check) => { + await check(); + return false; + }) + .mockImplementationOnce(async (check) => { + await check(); + return false; + }) + .mockImplementationOnce(async (check) => { + await check(); + return true; + }); + jest.spyOn(process, 'kill').mockImplementation(() => true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(process.kill).toHaveBeenCalledWith(123, 'SIGKILL'); + }); + + it('returns false when all strategies fail', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + mockWaitFor.mockResolvedValue(false); + jest.spyOn(process, 'kill').mockImplementation(() => true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(false); + }); + + it('treats process.kill throw on SIGTERM as stopped', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(false); + jest.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('process gone'); + }); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); + + it('treats process.kill throw on SIGKILL as stopped', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + // Graceful fails, SIGTERM fails, then SIGKILL throw + mockWaitFor.mockResolvedValueOnce(false).mockResolvedValueOnce(false); + let callCount = 0; + jest.spyOn(process, 'kill').mockImplementation(() => { + callCount += 1; + if (callCount === 2) { + // SIGKILL call + throw new Error('process gone'); + } + return true; + }); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); + + it('treats sendCommand error as socket unresponsive', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockRejectedValue(new Error('socket error')); + mockWaitFor.mockResolvedValue(true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); +}); diff --git a/packages/wallet-cli/src/daemon/utils.test.ts b/packages/wallet-cli/src/daemon/utils.test.ts new file mode 100644 index 00000000000..294f65b8eab --- /dev/null +++ b/packages/wallet-cli/src/daemon/utils.test.ts @@ -0,0 +1,182 @@ +import { readFile } from 'node:fs/promises'; + +import { + isErrorWithCode, + isProcessAlive, + readPidFile, + sendSignal, + waitFor, + withTimeout, +} from './utils'; + +jest.mock('node:fs/promises'); + +const mockReadFile = jest.mocked(readFile); + +describe('isErrorWithCode', () => { + it('returns true for an Error with a matching code', () => { + const error = Object.assign(new Error('fail'), { code: 'ENOENT' }); + expect(isErrorWithCode(error, 'ENOENT')).toBe(true); + }); + + it('returns false for an Error with a different code', () => { + const error = Object.assign(new Error('fail'), { code: 'EPERM' }); + expect(isErrorWithCode(error, 'ENOENT')).toBe(false); + }); + + it('returns false for an Error without a code', () => { + expect(isErrorWithCode(new Error('fail'), 'ENOENT')).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(isErrorWithCode('not an error', 'ENOENT')).toBe(false); + expect(isErrorWithCode(null, 'ENOENT')).toBe(false); + expect(isErrorWithCode(undefined, 'ENOENT')).toBe(false); + }); +}); + +describe('readPidFile', () => { + it('returns the PID number from a valid file', async () => { + mockReadFile.mockResolvedValue('12345'); + expect(await readPidFile('/tmp/test.pid')).toBe(12345); + }); + + it('returns undefined for ENOENT', async () => { + mockReadFile.mockRejectedValue( + Object.assign(new Error('not found'), { code: 'ENOENT' }), + ); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('returns undefined for NaN content', async () => { + mockReadFile.mockResolvedValue('not-a-number'); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('returns undefined for zero', async () => { + mockReadFile.mockResolvedValue('0'); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('returns undefined for negative numbers', async () => { + mockReadFile.mockResolvedValue('-1'); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('rethrows non-ENOENT errors', async () => { + mockReadFile.mockRejectedValue( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + await expect(readPidFile('/tmp/test.pid')).rejects.toThrow( + 'permission denied', + ); + }); +}); + +describe('isProcessAlive', () => { + it('returns true when process.kill(pid, 0) succeeds', () => { + jest.spyOn(process, 'kill').mockImplementation(() => true); + expect(isProcessAlive(123)).toBe(true); + }); + + it('returns true on EPERM (process exists but no permission)', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }); + expect(isProcessAlive(123)).toBe(true); + }); + + it('returns false on other errors', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('esrch'), { code: 'ESRCH' }); + }); + expect(isProcessAlive(123)).toBe(false); + }); +}); + +describe('sendSignal', () => { + it('returns true when signal is delivered', () => { + jest.spyOn(process, 'kill').mockImplementation(() => true); + expect(sendSignal(123, 'SIGTERM')).toBe(true); + }); + + it('returns false on ESRCH (process gone)', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('esrch'), { code: 'ESRCH' }); + }); + expect(sendSignal(123, 'SIGTERM')).toBe(false); + }); + + it('rethrows other errors', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }); + expect(() => sendSignal(123, 'SIGTERM')).toThrow('eperm'); + }); +}); + +describe('waitFor', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns true when check passes immediately', async () => { + expect(await waitFor(() => true, 1000)).toBe(true); + }); + + it('returns true when check passes after polling', async () => { + let calls = 0; + const check = (): boolean => { + calls += 1; + return calls >= 3; + }; + + const promise = waitFor(check, 5000); + await jest.advanceTimersByTimeAsync(500); + expect(await promise).toBe(true); + }); + + it('returns false on timeout', async () => { + const promise = waitFor(() => false, 500); + await jest.advanceTimersByTimeAsync(750); + expect(await promise).toBe(false); + }); + + it('works with async check functions', async () => { + let calls = 0; + const check = async (): Promise => { + calls += 1; + return calls >= 2; + }; + + const promise = waitFor(check, 5000); + await jest.advanceTimersByTimeAsync(500); + expect(await promise).toBe(true); + }); +}); + +describe('withTimeout', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('resolves with value if promise resolves before timeout', async () => { + expect(await withTimeout(Promise.resolve('result'), 1000)).toBe('result'); + }); + + it('rejects with timeout error if timeout fires first', async () => { + const neverResolves = new Promise(() => undefined); + const promise = withTimeout(neverResolves, 500); + + jest.advanceTimersByTime(500); + await expect(promise).rejects.toThrow('promise timed out after 500ms'); + }); +}); diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts index bda14c1ea72..66f73ee5561 100644 --- a/packages/wallet-cli/src/daemon/utils.ts +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -11,9 +11,7 @@ import { readFile } from 'node:fs/promises'; export function isErrorWithCode(error: unknown, code: string): boolean { return ( // TODO: use Error.isError() - error instanceof Error && - hasProperty(error, 'code') && - (error).code === code + error instanceof Error && hasProperty(error, 'code') && error.code === code ); } diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts new file mode 100644 index 00000000000..bb573b91f54 --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -0,0 +1,28 @@ +import { Wallet } from '@metamask/wallet'; + +import { createWallet } from './wallet-factory'; + +jest.mock('@metamask/wallet'); +jest.mock('@metamask/remote-feature-flag-controller'); + +const MockWallet = jest.mocked(Wallet); + +describe('createWallet', () => { + it('instantiates Wallet with the given infuraProjectId', () => { + createWallet({ infuraProjectId: 'test-key' }); + + expect(MockWallet).toHaveBeenCalledTimes(1); + const args = MockWallet.mock.calls[0][0]; + expect(args.options.infuraProjectId).toBe('test-key'); + }); + + it('uses expected default options', () => { + createWallet({ infuraProjectId: 'test-key' }); + + const args = MockWallet.mock.calls[0][0]; + expect(args.options.clientVersion).toBe('0.0.0'); + expect(args.options.showApprovalRequest()).toBeUndefined(); + expect(args.options.getMetaMetricsId()).toBe('cli'); + expect(args.options.clientConfigApiService).toBeDefined(); + }); +}); From db07836105a5f6645ab5e53ec5376e5aa96625fd Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:41:46 -0700 Subject: [PATCH 05/32] fix: Fix error serialization in rpc socket server --- .../src/daemon/rpc-socket-server.test.ts | 49 ++++++++++--------- .../src/daemon/rpc-socket-server.ts | 30 +++++++++--- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts index 10446030dea..9ff8e82a7a9 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -214,10 +214,12 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect(getResponse(socket).error).toStrictEqual({ - code: -32600, - message: 'Invalid request: missing method', - }); + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32600, + message: 'Invalid request: missing method', + }), + ); }); it('returns -32601 for unknown method', async () => { @@ -237,10 +239,12 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect(getResponse(socket).error).toStrictEqual({ - code: -32601, - message: 'Method not found: nonexistent', - }); + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32601, + message: 'Method not found: nonexistent', + }), + ); }); it('returns -32603 when handler throws an Error', async () => { @@ -260,20 +264,19 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect(getResponse(socket).error).toStrictEqual({ - code: -32603, - message: 'handler failed', - }); + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32603, + message: 'handler failed', + }), + ); }); - it('uses error code when handler throws an RPC error', async () => { + it('passes through RPC error objects when handler throws one', async () => { const { simulateConnection } = createMockServer(); + const rpcError = { code: -32001, message: 'custom rpc' }; const handlers: RpcHandlerMap = { - failing: jest - .fn() - .mockRejectedValue( - Object.assign(new Error('custom rpc'), { code: -32001 }), - ), + failing: jest.fn().mockRejectedValue(rpcError), }; await startRpcSocketServer({ @@ -310,10 +313,12 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect(getResponse(socket).error).toStrictEqual({ - code: -32603, - message: 'Internal error', - }); + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32603, + message: 'Internal error', + }), + ); }); it('intercepts shutdown method and calls onShutdown', async () => { diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 0fdbdbb319d..4600fa3fd34 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -58,9 +58,11 @@ export async function startRpcSocketServer({ socket.end( `${JSON.stringify({ jsonrpc: '2.0', - error: rpcErrors.invalidRequest({ - message: 'Only one request per connection is allowed', - }), + error: rpcErrors + .invalidRequest({ + message: 'Only one request per connection is allowed', + }) + .serialize(), })}\n`, ); return; @@ -75,7 +77,9 @@ export async function startRpcSocketServer({ socket.end( `${JSON.stringify({ jsonrpc: '2.0', - error: rpcErrors.internal({ message: 'Internal error' }), + error: rpcErrors + .internal({ message: 'Internal error' }) + .serialize(), })}\n`, ); }); @@ -132,7 +136,9 @@ async function handleRequest( return { jsonrpc: '2.0', id, - error: { code: -32600, message: 'Invalid request: missing method' }, + error: rpcErrors + .invalidRequest({ message: 'Invalid request: missing method' }) + .serialize(), }; } @@ -153,7 +159,9 @@ async function handleRequest( return { jsonrpc: '2.0', id, - error: { code: -32601, message: `Method not found: ${method}` }, + error: rpcErrors + .methodNotFound({ message: `Method not found: ${method}` }) + .serialize(), }; } @@ -161,9 +169,15 @@ async function handleRequest( const result = await handler(params); return { jsonrpc: '2.0', id, result: result ?? null }; } catch (error) { - const code = isRpcError(error) ? error.code : -32603; + if (isRpcError(error)) { + return { jsonrpc: '2.0', id, error }; + } const message = error instanceof Error ? error.message : 'Internal error'; - return { jsonrpc: '2.0', id, error: { code, message } }; + return { + jsonrpc: '2.0', + id, + error: rpcErrors.internal({ message }).serialize(), + }; } } From df39960d12f29dfd60e8b59e350bac721978d077 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:48:36 -0700 Subject: [PATCH 06/32] fix(wallet-cli): Use sendSignal in stopDaemon, remove dead withTimeout Wire sendSignal into stopDaemon so EPERM errors from process.kill are not silently treated as successful stops. Remove unused withTimeout utility. Co-Authored-By: Claude Opus 4.6 --- .../wallet-cli/src/daemon/stop-daemon.test.ts | 71 +++++++++++-------- packages/wallet-cli/src/daemon/stop-daemon.ts | 24 ++++--- packages/wallet-cli/src/daemon/utils.test.ts | 23 ------ packages/wallet-cli/src/daemon/utils.ts | 22 ------ 4 files changed, 56 insertions(+), 84 deletions(-) diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts index 3bb6e0a8eea..d03fd6cc115 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.test.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -2,7 +2,7 @@ import { rm } from 'node:fs/promises'; import { pingDaemon, sendCommand } from './daemon-client'; import { stopDaemon } from './stop-daemon'; -import { isProcessAlive, readPidFile, waitFor } from './utils'; +import { isProcessAlive, readPidFile, sendSignal, waitFor } from './utils'; jest.mock('node:fs/promises'); jest.mock('./daemon-client'); @@ -13,6 +13,7 @@ const mockPingDaemon = jest.mocked(pingDaemon); const mockSendCommand = jest.mocked(sendCommand); const mockReadPidFile = jest.mocked(readPidFile); const mockIsProcessAlive = jest.mocked(isProcessAlive); +const mockSendSignal = jest.mocked(sendSignal); const mockWaitFor = jest.mocked(waitFor); describe('stopDaemon', () => { @@ -74,6 +75,7 @@ describe('stopDaemon', () => { id: '1', result: null, }); + mockSendSignal.mockReturnValue(true); // First waitFor (graceful) invokes cb and fails, second (SIGTERM) invokes cb and succeeds mockWaitFor .mockImplementationOnce(async (check) => { @@ -84,11 +86,10 @@ describe('stopDaemon', () => { await check(); return true; }); - jest.spyOn(process, 'kill').mockImplementation(() => true); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); - expect(process.kill).toHaveBeenCalledWith(123, 'SIGTERM'); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGTERM'); }); it('falls through to SIGKILL when SIGTERM times out', async () => { @@ -100,6 +101,7 @@ describe('stopDaemon', () => { id: '1', result: null, }); + mockSendSignal.mockReturnValue(true); // All three waitFor calls invoke check, graceful + SIGTERM fail, SIGKILL succeeds mockWaitFor .mockImplementationOnce(async (check) => { @@ -114,11 +116,10 @@ describe('stopDaemon', () => { await check(); return true; }); - jest.spyOn(process, 'kill').mockImplementation(() => true); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); - expect(process.kill).toHaveBeenCalledWith(123, 'SIGKILL'); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGKILL'); }); it('returns false when all strategies fail', async () => { @@ -130,48 +131,62 @@ describe('stopDaemon', () => { id: '1', result: null, }); + mockSendSignal.mockReturnValue(true); mockWaitFor.mockResolvedValue(false); - jest.spyOn(process, 'kill').mockImplementation(() => true); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(false); }); - it('treats process.kill throw on SIGTERM as stopped', async () => { + it('treats ESRCH on SIGTERM as stopped', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); mockPingDaemon.mockResolvedValue(false); - jest.spyOn(process, 'kill').mockImplementation(() => { - throw new Error('process gone'); - }); + mockSendSignal.mockReturnValue(false); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); }); - it('treats process.kill throw on SIGKILL as stopped', async () => { + it('treats ESRCH on SIGKILL as stopped', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(true); - mockSendCommand.mockResolvedValue({ - jsonrpc: '2.0', - id: '1', - result: null, - }); - // Graceful fails, SIGTERM fails, then SIGKILL throw - mockWaitFor.mockResolvedValueOnce(false).mockResolvedValueOnce(false); - let callCount = 0; - jest.spyOn(process, 'kill').mockImplementation(() => { - callCount += 1; - if (callCount === 2) { - // SIGKILL call - throw new Error('process gone'); - } - return true; - }); + mockPingDaemon.mockResolvedValue(false); + // SIGTERM signal sent but process doesn't die, SIGKILL finds it gone + mockSendSignal.mockReturnValueOnce(true).mockReturnValueOnce(false); + mockWaitFor.mockResolvedValueOnce(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGKILL'); + }); + + it('falls through to SIGKILL when SIGTERM throws EPERM', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(false); + mockSendSignal + .mockImplementationOnce(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }) + .mockReturnValueOnce(true); + mockWaitFor.mockResolvedValueOnce(true); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGKILL'); + }); + + it('returns false when both SIGTERM and SIGKILL throw EPERM', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(false); + mockSendSignal.mockImplementation(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(false); }); it('treats sendCommand error as socket unresponsive', async () => { diff --git a/packages/wallet-cli/src/daemon/stop-daemon.ts b/packages/wallet-cli/src/daemon/stop-daemon.ts index e0b916281fa..24a47f158cf 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.ts @@ -1,7 +1,7 @@ import { rm } from 'node:fs/promises'; import { pingDaemon, sendCommand } from './daemon-client'; -import { isProcessAlive, readPidFile, waitFor } from './utils'; +import { isProcessAlive, readPidFile, sendSignal, waitFor } from './utils'; /** * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if @@ -45,24 +45,26 @@ export async function stopDaemon( // Strategy 2: SIGTERM. if (!stopped && pid !== undefined) { try { - process.kill(pid, 'SIGTERM'); + if (sendSignal(pid, 'SIGTERM')) { + stopped = await waitFor(() => !isProcessAlive(pid), 5_000); + } else { + stopped = true; // Process already gone (ESRCH). + } } catch { - stopped = true; - } - if (!stopped) { - stopped = await waitFor(() => !isProcessAlive(pid), 5_000); + // Permission error — fall through to next strategy. } } // Strategy 3: SIGKILL. if (!stopped && pid !== undefined) { try { - process.kill(pid, 'SIGKILL'); + if (sendSignal(pid, 'SIGKILL')) { + stopped = await waitFor(() => !isProcessAlive(pid), 2_000); + } else { + stopped = true; // Process already gone (ESRCH). + } } catch { - stopped = true; - } - if (!stopped) { - stopped = await waitFor(() => !isProcessAlive(pid), 2_000); + // Permission error — cannot kill process. } } diff --git a/packages/wallet-cli/src/daemon/utils.test.ts b/packages/wallet-cli/src/daemon/utils.test.ts index 294f65b8eab..78b678078f4 100644 --- a/packages/wallet-cli/src/daemon/utils.test.ts +++ b/packages/wallet-cli/src/daemon/utils.test.ts @@ -6,7 +6,6 @@ import { readPidFile, sendSignal, waitFor, - withTimeout, } from './utils'; jest.mock('node:fs/promises'); @@ -158,25 +157,3 @@ describe('waitFor', () => { expect(await promise).toBe(true); }); }); - -describe('withTimeout', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('resolves with value if promise resolves before timeout', async () => { - expect(await withTimeout(Promise.resolve('result'), 1000)).toBe('result'); - }); - - it('rejects with timeout error if timeout fires first', async () => { - const neverResolves = new Promise(() => undefined); - const promise = withTimeout(neverResolves, 500); - - jest.advanceTimersByTime(500); - await expect(promise).rejects.toThrow('promise timed out after 500ms'); - }); -}); diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts index 66f73ee5561..793eab67dfd 100644 --- a/packages/wallet-cli/src/daemon/utils.ts +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -94,25 +94,3 @@ export async function waitFor( } return await check(); } - -/** - * Wrap a promise with a timeout rejection. - * - * @param promise - The promise to wrap with a timeout. - * @param timeout - How many ms to wait before rejecting. - * @returns A wrapped promise which rejects after timeout milliseconds. - */ -export async function withTimeout( - promise: Promise, - timeout: number, -): Promise { - return Promise.race([ - promise, - new Promise((_resolve, reject) => - setTimeout( - () => reject(new Error(`promise timed out after ${timeout}ms`)), - timeout, - ), - ), - ]) as Promise; -} From e3f891ebab8f5f0a95fefdfb0dbe6fd794ad8aed Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:04:55 -0700 Subject: [PATCH 07/32] feat(wallet-cli): Add password and SRP flags to daemon start Thread wallet password and secret recovery phrase through the daemon startup chain so the wallet is initialized with an imported SRP. - Export importSecretRecoveryPhrase from @metamask/wallet - Add --password and --srp required flags to `daemon start` - Pass MM_WALLET_PASSWORD / MM_WALLET_SRP env vars to spawned daemon - Make createWallet async; call importSecretRecoveryPhrase after init Co-Authored-By: Claude Opus 4.6 --- .../wallet-cli/src/commands/daemon/start.ts | 17 +++- .../src/daemon/daemon-entry.test.ts | 98 +++++++++---------- .../wallet-cli/src/daemon/daemon-entry.ts | 12 ++- .../src/daemon/daemon-spawn.test.ts | 5 + .../wallet-cli/src/daemon/daemon-spawn.ts | 2 + packages/wallet-cli/src/daemon/types.ts | 2 + .../src/daemon/wallet-factory.test.ts | 32 +++++- .../wallet-cli/src/daemon/wallet-factory.ts | 20 +++- packages/wallet/src/index.ts | 3 +- 9 files changed, 125 insertions(+), 66 deletions(-) diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts index 92712778f66..6b467f761d0 100644 --- a/packages/wallet-cli/src/commands/daemon/start.ts +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -7,8 +7,8 @@ export default class DaemonStart extends Command { static override description = 'Start the wallet daemon'; static override examples = [ - '<%= config.bin %> daemon start --infura-project-id ', - 'INFURA_PROJECT_ID= <%= config.bin %> daemon start', + '<%= config.bin %> daemon start --infura-project-id --password --srp ', + 'INFURA_PROJECT_ID= MM_WALLET_PASSWORD= MM_WALLET_SRP= <%= config.bin %> daemon start', ]; static override flags = { @@ -17,11 +17,22 @@ export default class DaemonStart extends Command { env: 'INFURA_PROJECT_ID', required: true, }), + password: Flags.string({ + description: 'Wallet password', + env: 'MM_WALLET_PASSWORD', + required: true, + }), + srp: Flags.string({ + description: 'Secret recovery phrase (BIP-39 mnemonic)', + env: 'MM_WALLET_SRP', + required: true, + }), }; public async run(): Promise { const { flags } = await this.parse(DaemonStart); const infuraProjectId = flags['infura-project-id']; + const { password, srp } = flags; const { logPath, socketPath } = getDaemonPaths(this.config.dataDir); @@ -30,6 +41,8 @@ export default class DaemonStart extends Command { socketPath, logPath, infuraProjectId, + password, + srp, packageRoot: this.config.root, }); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 64d4fd5937f..33581954d7a 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -31,12 +31,12 @@ const ORIGINAL_ENV = process.env; * * @returns A mock wallet object. */ -function createMockWallet(): ReturnType { +function createMockWallet(): Awaited> { return { messenger: {} as never, state: {} as never, destroy: jest.fn().mockResolvedValue(undefined), - } as unknown as ReturnType; + } as unknown as Awaited>; } /** @@ -53,6 +53,11 @@ describe('daemon-entry', () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + process.env.MM_WALLET_PASSWORD = 'pass'; + process.env.MM_WALLET_SRP = + 'test test test test test test test test test test test ball'; process.exitCode = undefined; stderrSpy = jest .spyOn(process.stderr, 'write') @@ -89,7 +94,6 @@ describe('daemon-entry', () => { it('writes to stderr and sets exitCode when MM_DAEMON_DATA_DIR is missing', async () => { delete process.env.MM_DAEMON_DATA_DIR; - process.env.INFURA_PROJECT_ID = 'key'; await importDaemonEntry(); @@ -100,7 +104,6 @@ describe('daemon-entry', () => { }); it('writes to stderr and sets exitCode when INFURA_PROJECT_ID is missing', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; delete process.env.INFURA_PROJECT_ID; await importDaemonEntry(); @@ -111,12 +114,31 @@ describe('daemon-entry', () => { expect(process.exitCode).toBe(1); }); - it('creates data dir, wallet, server, and writes PID on successful startup', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; + it('writes to stderr and sets exitCode when MM_WALLET_PASSWORD is missing', async () => { + delete process.env.MM_WALLET_PASSWORD; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_WALLET_PASSWORD'), + ); + expect(process.exitCode).toBe(1); + }); + it('writes to stderr and sets exitCode when MM_WALLET_SRP is missing', async () => { + delete process.env.MM_WALLET_SRP; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_WALLET_SRP'), + ); + expect(process.exitCode).toBe(1); + }); + + it('creates data dir, wallet, server, and writes PID on successful startup', async () => { const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -125,7 +147,11 @@ describe('daemon-entry', () => { expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/data', { recursive: true, }); - expect(mockCreateWallet).toHaveBeenCalledWith({ infuraProjectId: 'key' }); + expect(mockCreateWallet).toHaveBeenCalledWith({ + infuraProjectId: 'key', + password: 'pass', + srp: 'test test test test test test test test test test test ball', + }); expect(mockWriteFile).toHaveBeenCalledWith( '/tmp/daemon.pid', String(process.pid), @@ -139,11 +165,9 @@ describe('daemon-entry', () => { }); it('uses MM_DAEMON_SOCKET_PATH override when set', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; process.env.MM_DAEMON_SOCKET_PATH = '/custom/sock'; - mockCreateWallet.mockReturnValue(createMockWallet()); + mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); await importDaemonEntry(); @@ -156,11 +180,8 @@ describe('daemon-entry', () => { }); it('cleans up wallet and PID file when server fails to start', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); await importDaemonEntry(); @@ -171,14 +192,11 @@ describe('daemon-entry', () => { }); it('still cleans up PID when wallet.destroy fails during error cleanup', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); (wallet.destroy as jest.Mock).mockRejectedValue( new Error('destroy failed'), ); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); await importDaemonEntry(); @@ -188,10 +206,7 @@ describe('daemon-entry', () => { }); it('exposes getStatus handler that returns pid and uptime', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - - mockCreateWallet.mockReturnValue(createMockWallet()); + mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); await importDaemonEntry(); @@ -209,10 +224,7 @@ describe('daemon-entry', () => { }); it('logs to file via makeLogger', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - - mockCreateWallet.mockReturnValue(createMockWallet()); + mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); await importDaemonEntry(); @@ -225,10 +237,7 @@ describe('daemon-entry', () => { }); it('registers SIGTERM and SIGINT handlers', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - - mockCreateWallet.mockReturnValue(createMockWallet()); + mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); const onSpy = jest.spyOn(process, 'on'); @@ -241,11 +250,8 @@ describe('daemon-entry', () => { }); it('sIGTERM handler calls shutdown and sets exitCode on failure', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -268,11 +274,8 @@ describe('daemon-entry', () => { }); it('sIGINT handler calls shutdown and sets exitCode on failure', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -293,11 +296,8 @@ describe('daemon-entry', () => { }); it('handles rm rejection during shutdown cleanup gracefully', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); // rm rejects but cleanup should not fail @@ -315,11 +315,8 @@ describe('daemon-entry', () => { }); it('handles rm rejection in error cleanup path gracefully', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); mockRm.mockRejectedValue(new Error('rm failed')); @@ -329,11 +326,8 @@ describe('daemon-entry', () => { }); it('onShutdown closes server and destroys wallet', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index da3b0c8d505..c48a9919c7d 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -28,6 +28,16 @@ async function main(): Promise { throw new Error('INFURA_PROJECT_ID environment variable is required'); } + const password = process.env.MM_WALLET_PASSWORD; + if (!password) { + throw new Error('MM_WALLET_PASSWORD environment variable is required'); + } + + const srp = process.env.MM_WALLET_SRP; + if (!srp) { + throw new Error('MM_WALLET_SRP environment variable is required'); + } + mkdirSync(dataDir, { recursive: true }); const { @@ -40,7 +50,7 @@ async function main(): Promise { const log = makeLogger(logPath); log('Starting daemon...'); - const wallet = createWallet({ infuraProjectId }); + const wallet = await createWallet({ infuraProjectId, password, srp }); const handlers: RpcHandlerMap = { getStatus: async () => ({ diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts index bac8ecfe66c..4585510eb95 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -18,6 +18,8 @@ const CONFIG: DaemonSpawnConfig = { socketPath: '/tmp/test.sock', logPath: '/tmp/daemon.log', infuraProjectId: 'test-key', + password: 'test-pass', + srp: 'test test test test test test test test test test test ball', packageRoot: '/pkg', }; @@ -52,6 +54,9 @@ describe('ensureDaemon', () => { MM_DAEMON_DATA_DIR: '/tmp/data', MM_DAEMON_SOCKET_PATH: '/tmp/test.sock', INFURA_PROJECT_ID: 'test-key', + MM_WALLET_PASSWORD: 'test-pass', + MM_WALLET_SRP: + 'test test test test test test test test test test test ball', }), }), ); diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index c0228d00fc8..79f6ad43a08 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -35,6 +35,8 @@ export async function ensureDaemon( MM_DAEMON_DATA_DIR: config.dataDir, MM_DAEMON_SOCKET_PATH: socketPath, INFURA_PROJECT_ID: config.infuraProjectId, + MM_WALLET_PASSWORD: config.password, + MM_WALLET_SRP: config.srp, }, }); child.unref(); diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index d4506c7e888..1c8e8e8dbed 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -27,5 +27,7 @@ export type DaemonSpawnConfig = { socketPath: string; logPath: string; infuraProjectId: string; + password: string; + srp: string; packageRoot: string; }; diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts index bb573b91f54..16898475912 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -1,4 +1,4 @@ -import { Wallet } from '@metamask/wallet'; +import { importSecretRecoveryPhrase, Wallet } from '@metamask/wallet'; import { createWallet } from './wallet-factory'; @@ -6,18 +6,25 @@ jest.mock('@metamask/wallet'); jest.mock('@metamask/remote-feature-flag-controller'); const MockWallet = jest.mocked(Wallet); +const mockImportSrp = jest.mocked(importSecretRecoveryPhrase); + +const CONFIG = { + infuraProjectId: 'test-key', + password: 'test-pass', + srp: 'test test test test test test test test test test test ball', +}; describe('createWallet', () => { - it('instantiates Wallet with the given infuraProjectId', () => { - createWallet({ infuraProjectId: 'test-key' }); + it('instantiates Wallet with the given infuraProjectId', async () => { + await createWallet(CONFIG); expect(MockWallet).toHaveBeenCalledTimes(1); const args = MockWallet.mock.calls[0][0]; expect(args.options.infuraProjectId).toBe('test-key'); }); - it('uses expected default options', () => { - createWallet({ infuraProjectId: 'test-key' }); + it('uses expected default options', async () => { + await createWallet(CONFIG); const args = MockWallet.mock.calls[0][0]; expect(args.options.clientVersion).toBe('0.0.0'); @@ -25,4 +32,19 @@ describe('createWallet', () => { expect(args.options.getMetaMetricsId()).toBe('cli'); expect(args.options.clientConfigApiService).toBeDefined(); }); + + it('imports the secret recovery phrase with the given password', async () => { + await createWallet(CONFIG); + + expect(mockImportSrp).toHaveBeenCalledWith( + expect.any(Wallet), + 'test-pass', + 'test test test test test test test test test test test ball', + ); + }); + + it('returns the wallet instance', async () => { + const wallet = await createWallet(CONFIG); + expect(wallet).toBeInstanceOf(Wallet); + }); }); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 8a975e03a6c..7f98995e65e 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -4,21 +4,27 @@ import { DistributionType, EnvironmentType, } from '@metamask/remote-feature-flag-controller'; -import { Wallet } from '@metamask/wallet'; +import { importSecretRecoveryPhrase, Wallet } from '@metamask/wallet'; /** * Create a configured Wallet instance for daemon use. * * @param config - Wallet configuration. * @param config.infuraProjectId - The Infura project ID for network access. - * @returns A new Wallet instance. + * @param config.password - The wallet password. + * @param config.srp - The secret recovery phrase (BIP-39 mnemonic). + * @returns A new Wallet instance with the SRP imported. */ -export function createWallet({ +export async function createWallet({ infuraProjectId, + password, + srp, }: { infuraProjectId: string; -}): Wallet { - return new Wallet({ + password: string; + srp: string; +}): Promise { + const wallet = new Wallet({ options: { infuraProjectId, clientVersion: '0.0.0', @@ -35,4 +41,8 @@ export function createWallet({ getMetaMetricsId: () => 'cli', }, }); + + await importSecretRecoveryPhrase(wallet, password, srp); + + return wallet; } diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 5fa0502ec2b..18f13fa92f7 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1,8 +1,9 @@ export { Wallet } from './Wallet'; -export type { WalletOptions } from './types'; export type { DefaultActions, DefaultEvents, RootMessenger, WalletDestroyedEvent, } from './initialization'; +export type { WalletOptions } from './types'; +export { importSecretRecoveryPhrase } from './utilities'; From 7061a4b8c67cb4c35df6c9f9a280dc2770e1b13f Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:26:06 -0700 Subject: [PATCH 08/32] feat(wallet-cli): Add daemon call command for invoking messenger actions Adds a `daemon call` CLI command that forwards arbitrary messenger action calls to the running wallet daemon over JSON-RPC. The daemon registers a `call` RPC handler that invokes `wallet.messenger.call()` with the provided action name and arguments. Usage: wallet-cli daemon call AccountsController:listAccounts wallet-cli daemon call NetworkController:getState --timeout 10000 Also fixes lint errors in wallet-factory.ts (missing return types). Co-Authored-By: Claude Opus 4.6 (1M context) --- eslint.config.mjs | 1 + .../wallet-cli/src/commands/daemon/call.ts | 80 ++++++++++++++++++ .../src/daemon/daemon-entry.test.ts | 84 ++++++++++++++++++- .../wallet-cli/src/daemon/daemon-entry.ts | 7 ++ .../wallet-cli/src/daemon/wallet-factory.ts | 4 +- 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 packages/wallet-cli/src/commands/daemon/call.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 136b01ee69d..3f4d57e50ad 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -310,6 +310,7 @@ const config = createConfig([ { files: ['packages/wallet-cli/src/**/*.test.{js,ts}'], rules: { + 'jest/unbound-method': 'off', 'n/no-process-env': 'off', 'n/no-sync': 'off', }, diff --git a/packages/wallet-cli/src/commands/daemon/call.ts b/packages/wallet-cli/src/commands/daemon/call.ts new file mode 100644 index 00000000000..8feac20ff7a --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/call.ts @@ -0,0 +1,80 @@ +import { isJsonRpcFailure } from '@metamask/utils'; +import { Args, Command, Flags } from '@oclif/core'; + +import { sendCommand } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; + +export default class DaemonCall extends Command { + static override description = 'Call a messenger action on the wallet daemon'; + + static override examples = [ + '<%= config.bin %> daemon call AccountsController:listAccounts', + '<%= config.bin %> daemon call NetworkController:getState', + '<%= config.bin %> daemon call KeyringController:getState --timeout 10000', + ]; + + static override args = { + action: Args.string({ + description: + 'The messenger action name (e.g. AccountsController:listAccounts)', + required: true, + }), + params: Args.string({ + description: 'JSON-encoded arguments array (e.g. \'["arg1", "arg2"]\')', + required: false, + }), + }; + + static override flags = { + timeout: Flags.integer({ + char: 't', + description: 'Response timeout in milliseconds', + required: false, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(DaemonCall); + const { action } = args; + const timeoutMs = flags.timeout; + + // Build the params array for the `call` RPC method: [action, ...args] + let rpcParams: unknown[] = [action]; + if (args.params !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(args.params); + } catch { + this.error('params must be valid JSON'); + } + + if (!Array.isArray(parsed)) { + this.error('params must be a JSON array'); + } + + rpcParams = [action, ...parsed]; + } + + const { socketPath } = getDaemonPaths(this.config.dataDir); + + const response = await sendCommand({ + socketPath, + method: 'call', + params: rpcParams, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + + if (isJsonRpcFailure(response)) { + this.error( + `${response.error.message} (code ${String(response.error.code)})`, + ); + } + + const isTTY = process.stdout.isTTY ?? false; + if (isTTY) { + this.log(JSON.stringify(response.result, null, 2)); + } else { + process.stdout.write(`${JSON.stringify(response.result)}\n`); + } + } +} diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 33581954d7a..5f9845870d9 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -33,7 +33,7 @@ const ORIGINAL_ENV = process.env; */ function createMockWallet(): Awaited> { return { - messenger: {} as never, + messenger: { call: jest.fn() } as never, state: {} as never, destroy: jest.fn().mockResolvedValue(undefined), } as unknown as Awaited>; @@ -343,4 +343,86 @@ describe('daemon-entry', () => { expect(wallet.destroy).toHaveBeenCalled(); expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); }); + + describe('call handler', () => { + /** + * Import the daemon entry and extract the `call` handler from the + * handlers map, along with the mock wallet for assertions. + * + * @returns The call handler function and mock wallet. + */ + async function setupCallHandler(): Promise<{ + callHandler: (params: unknown) => Promise; + wallet: Awaited>; + }> { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const callHandler = callArgs.handlers.call as ( + params: unknown, + ) => Promise; + return { callHandler, wallet }; + } + + it('registers a call handler', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + expect(typeof callArgs.handlers.call).toBe('function'); + }); + + it('forwards action and args to messenger.call', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockReturnValue({ accounts: [] }); + + const result = await callHandler(['Controller:action', 'arg1', 'arg2']); + + expect(mockCall).toHaveBeenCalledWith( + 'Controller:action', + 'arg1', + 'arg2', + ); + expect(result).toStrictEqual({ accounts: [] }); + }); + + it('calls messenger.call with no extra args when only action is provided', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockReturnValue('ok'); + + await callHandler(['Controller:action']); + + expect(mockCall).toHaveBeenCalledWith('Controller:action'); + }); + + it('awaits async messenger.call results', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockResolvedValue({ async: true }); + + const result = await callHandler(['Controller:asyncAction']); + + expect(result).toStrictEqual({ async: true }); + }); + + it('propagates errors thrown by messenger.call', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockImplementation(() => { + throw new Error('A handler for Unknown:action has not been registered'); + }); + + await expect(callHandler(['Unknown:action'])).rejects.toThrow( + 'A handler for Unknown:action has not been registered', + ); + }); + }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index c48a9919c7d..09bc1a2e8b2 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -1,3 +1,4 @@ +import type { Json } from '@metamask/utils'; import { appendFileSync, mkdirSync } from 'node:fs'; import { rm, writeFile } from 'node:fs/promises'; @@ -57,6 +58,12 @@ async function main(): Promise { pid: process.pid, uptime: Math.floor((Date.now() - startTime) / 1000), }), + call: async (params) => { + const [action, ...args] = params as [string, ...Json[]]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC. + const result = (wallet.messenger as any).call(action, ...args); + return (result instanceof Promise ? await result : result) as Json; + }, }; let handle: RpcSocketServerHandle; diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 7f98995e65e..7707d73a672 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -29,7 +29,7 @@ export async function createWallet({ infuraProjectId, clientVersion: '0.0.0', // TODO: Implement showApprovalRequest - showApprovalRequest: () => undefined, + showApprovalRequest: (): undefined => undefined, clientConfigApiService: new ClientConfigApiService({ fetch: globalThis.fetch, config: { @@ -38,7 +38,7 @@ export async function createWallet({ environment: EnvironmentType.Production, }, }), - getMetaMetricsId: () => 'cli', + getMetaMetricsId: (): string => 'cli', }, }); From 4c7cc3916a5d94873ba6840be30fa223954fb044 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:34:55 -0700 Subject: [PATCH 09/32] chore: Fix codeowners --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d834e3a3732..e8adb20f01d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -127,6 +127,8 @@ /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/wallet @MetaMask/core-platform @rekmarks @FrederikBolding +/packages/wallet-cli @MetaMask/core-platform @rekmarks @FrederikBolding ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform From cc2f721623f67cc0fd4c831efedc9a4c81812c83 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:01:42 -0700 Subject: [PATCH 10/32] fix(wallet-cli): Address PR review findings - Remove placeholder index.ts and library exports (CLI-only package) - Add yarn constraints exception for wallet-cli exports - Document intentional `as any` messenger dispatch in daemon-entry - Remove duplicate socketPath param from ensureDaemon - Remove unused logPath from DaemonSpawnConfig - Add 30s server-side socket read timeout in rpc-socket-server - Handle sendCommand throwing in status command - Have purge remove entire data directory - Tighten isRpcError to require both code and message - Fix waitFor to return false on timeout instead of re-checking - Clarify multi-request rejection test assertions - Document password/srp CLI flags as testing-only Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wallet-cli/package.json | 12 ---- .../wallet-cli/src/commands/daemon/purge.ts | 10 +--- .../wallet-cli/src/commands/daemon/start.ts | 12 ++-- .../wallet-cli/src/commands/daemon/status.ts | 16 +++-- .../wallet-cli/src/daemon/daemon-entry.ts | 3 + .../src/daemon/daemon-spawn.test.ts | 15 +++-- .../wallet-cli/src/daemon/daemon-spawn.ts | 7 +-- .../src/daemon/rpc-socket-server.test.ts | 59 ++++++++++++++++++- .../src/daemon/rpc-socket-server.ts | 17 +++++- packages/wallet-cli/src/daemon/types.ts | 1 - packages/wallet-cli/src/daemon/utils.ts | 2 +- packages/wallet-cli/src/index.test.ts | 9 --- packages/wallet-cli/src/index.ts | 9 --- yarn.config.cjs | 3 +- 14 files changed, 106 insertions(+), 69 deletions(-) delete mode 100644 packages/wallet-cli/src/index.test.ts delete mode 100644 packages/wallet-cli/src/index.ts diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 7400120ce75..edbd684cb08 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -17,20 +17,8 @@ "license": "MIT", "sideEffects": false, "exports": { - ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - }, "./package.json": "./package.json" }, - "main": "./dist/index.cjs", - "types": "./dist/index.d.cts", "bin": { "mm": "./bin/run.mjs" }, diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts index 6f4f113dbad..cb4e969fff0 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -21,9 +21,7 @@ export default class DaemonPurge extends Command { public async run(): Promise { await this.parse(DaemonPurge); - const { socketPath, pidPath, logPath } = getDaemonPaths( - this.config.dataDir, - ); + const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); const stopped = await stopDaemon(socketPath, pidPath, (message) => this.log(message), @@ -33,11 +31,7 @@ export default class DaemonPurge extends Command { this.error('Refusing to delete state while the daemon is still running.'); } - await Promise.all([ - rm(socketPath, { force: true }), - rm(pidPath, { force: true }), - rm(logPath, { force: true }), - ]); + await rm(this.config.dataDir, { recursive: true, force: true }); this.log('All daemon state deleted.'); } diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts index 6b467f761d0..61771e5658f 100644 --- a/packages/wallet-cli/src/commands/daemon/start.ts +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -11,6 +11,7 @@ export default class DaemonStart extends Command { 'INFURA_PROJECT_ID= MM_WALLET_PASSWORD= MM_WALLET_SRP= <%= config.bin %> daemon start', ]; + // TODO: Delete unsafe flags static override flags = { 'infura-project-id': Flags.string({ description: 'Infura project ID for network access', @@ -18,12 +19,14 @@ export default class DaemonStart extends Command { required: true, }), password: Flags.string({ - description: 'Wallet password', + description: + 'Wallet password (testing only — use MM_WALLET_PASSWORD env var in production)', env: 'MM_WALLET_PASSWORD', required: true, }), srp: Flags.string({ - description: 'Secret recovery phrase (BIP-39 mnemonic)', + description: + 'Secret recovery phrase (testing only — use MM_WALLET_SRP env var in production)', env: 'MM_WALLET_SRP', required: true, }), @@ -34,12 +37,11 @@ export default class DaemonStart extends Command { const infuraProjectId = flags['infura-project-id']; const { password, srp } = flags; - const { logPath, socketPath } = getDaemonPaths(this.config.dataDir); + const { socketPath } = getDaemonPaths(this.config.dataDir); - await ensureDaemon(socketPath, { + await ensureDaemon({ dataDir: this.config.dataDir, socketPath, - logPath, infuraProjectId, password, srp, diff --git a/packages/wallet-cli/src/commands/daemon/status.ts b/packages/wallet-cli/src/commands/daemon/status.ts index d2d144ef1cd..38f30f479a3 100644 --- a/packages/wallet-cli/src/commands/daemon/status.ts +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -29,11 +29,17 @@ export default class DaemonStatus extends Command { return; } - const response = await sendCommand({ - socketPath, - method: 'getStatus', - timeoutMs: 5_000, - }); + let response; + try { + response = await sendCommand({ + socketPath, + method: 'getStatus', + timeoutMs: 5_000, + }); + } catch { + this.log('Daemon socket is responsive but status request failed.'); + return; + } if (isJsonRpcFailure(response)) { this.log( diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 09bc1a2e8b2..37b555f525e 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -58,6 +58,9 @@ async function main(): Promise { pid: process.pid, uptime: Math.floor((Date.now() - startTime) / 1000), }), + // Arbitrary messenger dispatch is intentional: the CLI exposes the full + // messenger surface over a local Unix socket. Access control is enforced + // at the socket level (only local users can connect). call: async (params) => { const [action, ...args] = params as [string, ...Json[]]; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC. diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts index 4585510eb95..a8d74d96124 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -16,7 +16,6 @@ const mockPingDaemon = jest.mocked(pingDaemon); const CONFIG: DaemonSpawnConfig = { dataDir: '/tmp/data', socketPath: '/tmp/test.sock', - logPath: '/tmp/daemon.log', infuraProjectId: 'test-key', password: 'test-pass', srp: 'test test test test test test test test test test test ball', @@ -34,7 +33,7 @@ describe('ensureDaemon', () => { it('returns immediately if daemon is already running', async () => { mockPingDaemon.mockResolvedValue(true); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); expect(mockSpawn).not.toHaveBeenCalled(); }); @@ -42,7 +41,7 @@ describe('ensureDaemon', () => { mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mockExistsSync.mockReturnValue(true); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); expect(mockSpawn).toHaveBeenCalledWith( process.execPath, @@ -66,7 +65,7 @@ describe('ensureDaemon', () => { mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mockExistsSync.mockReturnValue(true); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).toStrictEqual(['/pkg/dist/daemon/daemon-entry.mjs']); @@ -76,7 +75,7 @@ describe('ensureDaemon', () => { mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mockExistsSync.mockReturnValue(false); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).toStrictEqual([ @@ -94,7 +93,7 @@ describe('ensureDaemon', () => { .mockResolvedValueOnce(true); // poll 3 mockExistsSync.mockReturnValue(true); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); expect(mockPingDaemon).toHaveBeenCalledTimes(4); expect(process.stderr.write).toHaveBeenCalledWith('Daemon ready.\n'); @@ -105,7 +104,7 @@ describe('ensureDaemon', () => { mockPingDaemon.mockResolvedValue(false); mockExistsSync.mockReturnValue(true); - const promise = ensureDaemon('/tmp/test.sock', CONFIG); + const promise = ensureDaemon(CONFIG); // Attach rejection handler before advancing timers to avoid unhandled rejection const rejection = promise.catch((thrown: unknown) => thrown); @@ -125,7 +124,7 @@ describe('ensureDaemon', () => { mockExistsSync.mockReturnValue(true); mockSpawn.mockReturnValue({ unref } as never); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); expect(unref).toHaveBeenCalled(); }); diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index 79f6ad43a08..75d1611b8a9 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -12,13 +12,10 @@ const MAX_POLLS = 300; // 30 seconds * Ensure the daemon is running. If it is not, spawn it as a detached process * and wait until the socket becomes responsive. * - * @param socketPath - The Unix socket path. * @param config - Spawn configuration. */ -export async function ensureDaemon( - socketPath: string, - config: DaemonSpawnConfig, -): Promise { +export async function ensureDaemon(config: DaemonSpawnConfig): Promise { + const { socketPath } = config; if (await pingDaemon(socketPath)) { return; } diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts index 9ff8e82a7a9..7633477db36 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -396,6 +396,8 @@ describe('startRpcSocketServer', () => { const socket = createMockSocket(); simulateConnection(socket); + + // Send a valid request followed by extra data after the newline. socket.emit( 'data', Buffer.from( @@ -403,9 +405,13 @@ describe('startRpcSocketServer', () => { ), ); - const endCall = (socket.end as jest.Mock).mock.calls[0][0] as string; - const response = JSON.parse(endCall.trim()) as Record; - expect(response.error).toBeDefined(); + const response = getResponse(socket); + expect(response.error).toStrictEqual( + expect.objectContaining({ + code: -32600, + message: 'Only one request per connection is allowed', + }), + ); }); it('accumulates partial data across multiple events', async () => { @@ -506,5 +512,52 @@ describe('startRpcSocketServer', () => { expect(getResponse(socket).id).toBeNull(); }); + + it('destroys socket when no complete request arrives within timeout', async () => { + jest.useFakeTimers(); + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + // Send partial data (no newline). + socket.emit('data', Buffer.from('partial')); + + expect(socket.destroy).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(30_000); + + expect(socket.destroy).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('wraps thrown object with code but no message as internal error', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest.fn().mockRejectedValue({ code: 42 }), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32603, + message: 'Internal error', + }), + ); + }); }); }); diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 4600fa3fd34..12491b80a7b 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -28,6 +28,8 @@ export type RpcSocketServerHandle = { * @param options.onShutdown - Callback invoked when a `shutdown` RPC is received. * @returns A handle with a `close()` function for cleanup. */ +const CONNECTION_TIMEOUT_MS = 30_000; + export async function startRpcSocketServer({ socketPath, handlers, @@ -40,6 +42,11 @@ export async function startRpcSocketServer({ const server = createServer((socket) => { let buffer = ''; + // Destroy connections that never send a complete request line. + const timer = setTimeout(() => { + socket.destroy(); + }, CONNECTION_TIMEOUT_MS); + const onData = (data: Buffer): void => { buffer += data.toString(); const idx = buffer.indexOf('\n'); @@ -47,6 +54,8 @@ export async function startRpcSocketServer({ return; } + clearTimeout(timer); + // One request per connection. socket.removeListener('data', onData); @@ -187,12 +196,16 @@ async function handleRequest( * @param error - The error to check. * @returns True if the error has a numeric code property. */ -function isRpcError(error: unknown): error is { code: number } { +function isRpcError( + error: unknown, +): error is { code: number; message: string } { return ( typeof error === 'object' && error !== null && hasProperty(error, 'code') && - typeof error.code === 'number' + typeof error.code === 'number' && + hasProperty(error, 'message') && + typeof error.message === 'string' ); } diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index 1c8e8e8dbed..714f6e81a93 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -25,7 +25,6 @@ export type DaemonPaths = { export type DaemonSpawnConfig = { dataDir: string; socketPath: string; - logPath: string; infuraProjectId: string; password: string; srp: string; diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts index 793eab67dfd..3e1db9cdc04 100644 --- a/packages/wallet-cli/src/daemon/utils.ts +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -92,5 +92,5 @@ export async function waitFor( } await new Promise((resolve) => setTimeout(resolve, 250)); } - return await check(); + return false; } diff --git a/packages/wallet-cli/src/index.test.ts b/packages/wallet-cli/src/index.test.ts deleted file mode 100644 index bc062d3694a..00000000000 --- a/packages/wallet-cli/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/wallet-cli/src/index.ts b/packages/wallet-cli/src/index.ts deleted file mode 100644 index 6972c117292..00000000000 --- a/packages/wallet-cli/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} diff --git a/yarn.config.cjs b/yarn.config.cjs index 823af6d001b..34445ab5aec 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -119,7 +119,8 @@ module.exports = defineConfig({ // exports correctly. if ( workspace.ident !== '@metamask/foundryup' && - workspace.ident !== '@metamask/messenger-cli' + workspace.ident !== '@metamask/messenger-cli' && + workspace.ident !== '@metamask/wallet-cli' ) { expectCorrectWorkspaceExports(workspace); } From 635110eb262b3d2b8313ca2d15b1589131e35b2c Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:04:10 -0700 Subject: [PATCH 11/32] fix(wallet-cli): Validate params in call RPC handler Destructuring null params (when a request omits the params field) would throw a confusing TypeError. Add a runtime check that params is a non-empty array before destructuring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wallet-cli/src/daemon/daemon-entry.test.ts | 16 ++++++++++++++++ packages/wallet-cli/src/daemon/daemon-entry.ts | 3 +++ 2 files changed, 19 insertions(+) diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 5f9845870d9..9920e2333c0 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -424,5 +424,21 @@ describe('daemon-entry', () => { 'A handler for Unknown:action has not been registered', ); }); + + it('throws when params is null', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler(null)).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); + + it('throws when params is an empty array', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler([])).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 37b555f525e..bcde91b89d2 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -62,6 +62,9 @@ async function main(): Promise { // messenger surface over a local Unix socket. Access control is enforced // at the socket level (only local users can connect). call: async (params) => { + if (!Array.isArray(params) || params.length === 0) { + throw new Error('Expected params to be an array with an action name'); + } const [action, ...args] = params as [string, ...Json[]]; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC. const result = (wallet.messenger as any).call(action, ...args); From 0ecc1e1e4a5b249f5b3bde43667feae3665f9432 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:10:47 -0700 Subject: [PATCH 12/32] docs: Update readme dep graph --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e6ce9461b03..757a4e673ed 100644 --- a/README.md +++ b/README.md @@ -567,6 +567,8 @@ linkStyle default opacity:0.5 wallet --> remote_feature_flag_controller; wallet --> transaction_controller; wallet --> foundryup; + wallet_cli --> remote_feature_flag_controller; + wallet_cli --> wallet; ``` From 6e03ca1d1323ba0ee083d12299840b87d48ebe0e Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:30:50 -0700 Subject: [PATCH 13/32] chore: prettier --- packages/wallet-cli/package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index edbd684cb08..62674f030cb 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -40,6 +40,12 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "oclif": { + "bin": "mm", + "commands": "./dist/commands", + "dirname": "mm", + "topicSeparator": " " + }, "dependencies": { "@metamask/remote-feature-flag-controller": "^4.2.0", "@metamask/rpc-errors": "^7.0.2", @@ -65,11 +71,5 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" - }, - "oclif": { - "bin": "mm", - "commands": "./dist/commands", - "dirname": "mm", - "topicSeparator": " " } } From b37ee4c8e6a7210397c2522cf8739c4343f1c78d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:15:13 -0700 Subject: [PATCH 14/32] chore: Update teams.json and sync with CODEOWNERS --- .github/CODEOWNERS | 4 ++-- teams.json | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e8adb20f01d..99a206fb0fa 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -127,8 +127,8 @@ /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform -/packages/wallet @MetaMask/core-platform @rekmarks @FrederikBolding -/packages/wallet-cli @MetaMask/core-platform @rekmarks @FrederikBolding +/packages/wallet @MetaMask/core-platform +/packages/wallet-cli @MetaMask/core-platform ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform diff --git a/teams.json b/teams.json index 13d7ec6634f..3181ba1cf30 100644 --- a/teams.json +++ b/teams.json @@ -82,5 +82,6 @@ "metamask/money-account-controller": "team-accounts-framework", "metamask/money-account-upgrade-controller": "team-earn", "metamask/snap-account-service": "team-accounts-framework", - "metamask/wallet": "team-core-platform" + "metamask/wallet": "team-core-platform", + "metamask/wallet-cli": "team-core-platform" } From 49cbbd17f0cd04985b0dd8e2668e4fa633356802 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:38:31 -0700 Subject: [PATCH 15/32] fix(wallet-cli): Await shutdown rm calls, use -32700 parse error, fix typedoc and JSDoc - Await rm calls in shutdown finally block via Promise.all and in error cleanup path so callers know cleanup is complete - Use rpcErrors.parse() (-32700) for JSON.parse failures per JSON-RPC spec - Update typedoc.json entry points after index.ts removal - Move CONNECTION_TIMEOUT_MS above JSDoc so doc attaches to the function Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wallet-cli/src/daemon/daemon-entry.ts | 8 ++++--- .../src/daemon/rpc-socket-server.test.ts | 2 +- .../src/daemon/rpc-socket-server.ts | 21 ++++++++++++------- packages/wallet-cli/typedoc.json | 2 +- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index bcde91b89d2..c464028069a 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -87,7 +87,7 @@ async function main(): Promise { } catch { // Best-effort cleanup. } - rm(pidPath, { force: true }).catch(() => undefined); + await rm(pidPath, { force: true }).catch(() => undefined); throw error; } @@ -109,8 +109,10 @@ async function main(): Promise { await handle.close(); await wallet.destroy(); } finally { - rm(pidPath, { force: true }).catch(() => undefined); - rm(socketPath, { force: true }).catch(() => undefined); + await Promise.all([ + rm(pidPath, { force: true }).catch(() => undefined), + rm(socketPath, { force: true }).catch(() => undefined), + ]); } })(); } diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts index 7633477db36..61e68f207d0 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -494,7 +494,7 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect((getResponse(socket).error as { code: number }).code).toBe(-32603); + expect((getResponse(socket).error as { code: number }).code).toBe(-32700); }); it('uses null id when request has no id', async () => { diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 12491b80a7b..2f4a59c9fe0 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -6,6 +6,8 @@ import type { Server } from 'node:net'; import type { RpcHandlerMap } from './types'; +const CONNECTION_TIMEOUT_MS = 30_000; + /** * Handle returned by {@link startRpcSocketServer}. */ @@ -28,8 +30,6 @@ export type RpcSocketServerHandle = { * @param options.onShutdown - Callback invoked when a `shutdown` RPC is received. * @returns A handle with a `close()` function for cleanup. */ -const CONNECTION_TIMEOUT_MS = 30_000; - export async function startRpcSocketServer({ socketPath, handlers, @@ -131,14 +131,21 @@ async function handleRequest( onShutdown?: () => Promise, ): Promise> { let id: unknown = null; + let request: { id?: unknown; method?: string; params?: unknown }; try { - const request = JSON.parse(line) as { - id?: unknown; - method?: string; - params?: unknown; + request = JSON.parse(line) as typeof request; + } catch { + return { + jsonrpc: '2.0', + id: null, + error: rpcErrors.parse({ message: 'Parse error' }).serialize(), }; - id = request.id ?? null; + } + + id = request.id ?? null; + + try { const { method } = request; if (typeof method !== 'string') { diff --git a/packages/wallet-cli/typedoc.json b/packages/wallet-cli/typedoc.json index c9da015dbf8..cb2d25b4bbb 100644 --- a/packages/wallet-cli/typedoc.json +++ b/packages/wallet-cli/typedoc.json @@ -1,5 +1,5 @@ { - "entryPoints": ["./src/index.ts"], + "entryPoints": ["./src/daemon/daemon-client.ts", "./src/daemon/types.ts"], "excludePrivate": true, "hideGenerator": true, "out": "docs", From 7942feb8d583d8a953b9f96565d7f37769eb5920 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:41:22 -0700 Subject: [PATCH 16/32] fix(wallet-cli): Validate action name is a string in call handler Reject params where the first element is not a string, preventing confusing downstream errors from messenger.call. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wallet-cli/src/daemon/daemon-entry.test.ts | 8 ++++++++ packages/wallet-cli/src/daemon/daemon-entry.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 9920e2333c0..f85e42fba17 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -440,5 +440,13 @@ describe('daemon-entry', () => { 'Expected params to be an array with an action name', ); }); + + it('throws when action name is not a string', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler([42])).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index c464028069a..591f23c5d89 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -62,7 +62,7 @@ async function main(): Promise { // messenger surface over a local Unix socket. Access control is enforced // at the socket level (only local users can connect). call: async (params) => { - if (!Array.isArray(params) || params.length === 0) { + if (!Array.isArray(params) || typeof params[0] !== 'string') { throw new Error('Expected params to be an array with an action name'); } const [action, ...args] = params as [string, ...Json[]]; From 85d3c97beee5cd3e627ec0d19589b640a9df5ad6 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:16:29 -0700 Subject: [PATCH 17/32] fix(wallet-cli): Improve error handling, type safety, and cleanup - Log errors in catch blocks instead of silently swallowing them across daemon-entry, stop-daemon, and rpc-socket-server - Make shutdown cleanup independent: handle.close(), wallet.destroy(), and file removal each run in their own try/catch so one failure doesn't skip the others - Add default 30s timeout to sendCommand to prevent indefinite hangs - Use JsonRpcResponse from @metamask/utils as handleRequest return type - Add shared DaemonStatusInfo type for the getStatus RPC contract - Change RpcHandler to allow void return, document null params - Filter expected socket errors (EPIPE/ECONNRESET), log unexpected ones - Only suppress ENOENT in socket unlink, re-throw other errors - Clean up socket file (not just PID file) when stopDaemon succeeds - Add child.on('error') handler in daemon-spawn for spawn failures - Switch makeLogger from sync appendFileSync to async appendFile - Remove socketPath from DaemonSpawnConfig, derive from dataDir - Include error details in status command catch block Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wallet-cli/src/commands/daemon/start.ts | 4 +- .../wallet-cli/src/commands/daemon/status.ts | 9 +- .../wallet-cli/src/daemon/daemon-client.ts | 10 ++- .../src/daemon/daemon-entry.test.ts | 90 +++++++++++++++---- .../wallet-cli/src/daemon/daemon-entry.ts | 43 +++++---- .../src/daemon/daemon-spawn.test.ts | 39 +++++++- .../wallet-cli/src/daemon/daemon-spawn.ts | 7 +- .../src/daemon/rpc-socket-server.test.ts | 73 +++++++++++++-- .../src/daemon/rpc-socket-server.ts | 29 ++++-- .../wallet-cli/src/daemon/stop-daemon.test.ts | 2 + packages/wallet-cli/src/daemon/stop-daemon.ts | 17 ++-- packages/wallet-cli/src/daemon/types.ts | 13 ++- 12 files changed, 263 insertions(+), 73 deletions(-) diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts index 61771e5658f..e0223a99ce9 100644 --- a/packages/wallet-cli/src/commands/daemon/start.ts +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -37,17 +37,15 @@ export default class DaemonStart extends Command { const infuraProjectId = flags['infura-project-id']; const { password, srp } = flags; - const { socketPath } = getDaemonPaths(this.config.dataDir); - await ensureDaemon({ dataDir: this.config.dataDir, - socketPath, infuraProjectId, password, srp, packageRoot: this.config.root, }); + const { socketPath } = getDaemonPaths(this.config.dataDir); this.log(`Daemon running. Socket: ${socketPath}`); } } diff --git a/packages/wallet-cli/src/commands/daemon/status.ts b/packages/wallet-cli/src/commands/daemon/status.ts index 38f30f479a3..f8a2b618209 100644 --- a/packages/wallet-cli/src/commands/daemon/status.ts +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -3,6 +3,7 @@ import { Command } from '@oclif/core'; import { pingDaemon, sendCommand } from '../../daemon/daemon-client'; import { getDaemonPaths } from '../../daemon/paths'; +import type { DaemonStatusInfo } from '../../daemon/types'; import { isProcessAlive, readPidFile } from '../../daemon/utils'; export default class DaemonStatus extends Command { @@ -36,8 +37,10 @@ export default class DaemonStatus extends Command { method: 'getStatus', timeoutMs: 5_000, }); - } catch { - this.log('Daemon socket is responsive but status request failed.'); + } catch (error) { + this.log( + `Daemon socket is responsive but status request failed: ${error instanceof Error ? error.message : String(error)}`, + ); return; } @@ -48,7 +51,7 @@ export default class DaemonStatus extends Command { return; } - const status = response.result as { pid: number; uptime: number }; + const status = response.result as DaemonStatusInfo; this.log( `Daemon is running. PID: ${status.pid}, Uptime: ${status.uptime}s`, ); diff --git a/packages/wallet-cli/src/daemon/daemon-client.ts b/packages/wallet-cli/src/daemon/daemon-client.ts index 68ff9d39d39..dd80f06753e 100644 --- a/packages/wallet-cli/src/daemon/daemon-client.ts +++ b/packages/wallet-cli/src/daemon/daemon-client.ts @@ -6,6 +6,8 @@ import type { Socket } from 'node:net'; import { readLine, writeLine } from './socket-line'; +const DEFAULT_TIMEOUT_MS = 30_000; + /** * Options for {@link sendCommand}. */ @@ -16,7 +18,7 @@ type SendCommandOptions = { method: string; /** Optional method parameters (object or positional array). */ params?: Record | unknown[] | undefined; - /** Read timeout in milliseconds (default: no timeout). */ + /** Response read timeout in milliseconds (default: 30 000). */ timeoutMs?: number | undefined; }; @@ -42,7 +44,7 @@ async function connectSocket(socketPath: string): Promise { * * Opens a connection, writes one JSON-RPC request line, reads one JSON-RPC * response line, then closes the connection. Retries once after a short delay - * if the connection is rejected. + * on transient connection errors (ECONNREFUSED, ECONNRESET). * * @param options - Command options. * @param options.socketPath - The Unix socket path. @@ -65,11 +67,13 @@ export async function sendCommand({ ...(params === undefined ? {} : { params }), }; + const effectiveTimeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; + const attempt = async (): Promise => { const socket = await connectSocket(socketPath); try { await writeLine(socket, JSON.stringify(request)); - const responseLine = await readLine(socket, timeoutMs); + const responseLine = await readLine(socket, effectiveTimeout); const parsed: unknown = JSON.parse(responseLine); assertIsJsonRpcResponse(parsed); return parsed; diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index f85e42fba17..2e0e57193a4 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -1,5 +1,5 @@ -import { appendFileSync, mkdirSync } from 'node:fs'; -import { rm, writeFile } from 'node:fs/promises'; +import { mkdirSync } from 'node:fs'; +import { appendFile, rm, writeFile } from 'node:fs/promises'; import { getDaemonPaths } from './paths'; import { startRpcSocketServer } from './rpc-socket-server'; @@ -13,17 +13,13 @@ jest.mock('./rpc-socket-server'); jest.mock('./wallet-factory'); const mockMkdirSync = jest.mocked(mkdirSync); -const mockAppendFileSync = jest.mocked(appendFileSync); +const mockAppendFile = jest.mocked(appendFile); const mockWriteFile = jest.mocked(writeFile); const mockRm = jest.mocked(rm); const mockGetDaemonPaths = jest.mocked(getDaemonPaths); const mockStartRpcSocketServer = jest.mocked(startRpcSocketServer); const mockCreateWallet = jest.mocked(createWallet); -// The module under test calls main() at top level on import. -// We use jest.isolateModules to re-import it fresh in each test -// after setting up mocks and env vars. - const ORIGINAL_ENV = process.env; /** @@ -70,6 +66,7 @@ describe('daemon-entry', () => { }); mockWriteFile.mockResolvedValue(undefined); mockRm.mockResolvedValue(undefined); + mockAppendFile.mockResolvedValue(undefined); }); afterEach(() => { @@ -83,6 +80,9 @@ describe('daemon-entry', () => { * Returns after main() settles. */ async function importDaemonEntry(): Promise { + // The module under test calls main() at top level on import. + // We use jest.isolateModules to re-import it fresh in each test + // after setting up mocks and env vars. await jest.isolateModulesAsync(async () => { await import('./daemon-entry'); // Flush microtasks so main()'s .catch() handler settles @@ -229,13 +229,30 @@ describe('daemon-entry', () => { await importDaemonEntry(); - // makeLogger writes via appendFileSync to the log path - expect(mockAppendFileSync).toHaveBeenCalledWith( + // makeLogger writes via appendFile to the log path + expect(mockAppendFile).toHaveBeenCalledWith( '/tmp/daemon.log', expect.stringContaining('Starting daemon...'), ); }); + it('writes to stderr when appendFile fails in makeLogger', async () => { + mockAppendFile.mockRejectedValue(new Error('disk full')); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // Flush the appendFile rejection handler + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('log write failed'), + ); + }); + it('registers SIGTERM and SIGINT handlers', async () => { mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); @@ -249,35 +266,32 @@ describe('daemon-entry', () => { expect(registeredEvents).toContain('SIGINT'); }); - it('sIGTERM handler calls shutdown and sets exitCode on failure', async () => { + it('triggers shutdown when SIGTERM handler is called', async () => { const wallet = createMockWallet(); mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); - (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); mockStartRpcSocketServer.mockResolvedValue(handle); const onSpy = jest.spyOn(process, 'on'); await importDaemonEntry(); - // Find the SIGTERM handler and invoke it const sigTermCall = onSpy.mock.calls.find(([event]) => event === 'SIGTERM'); const sigTermHandler = sigTermCall?.[1] as () => void; sigTermHandler(); - // Flush promise chain for (let i = 0; i < 10; i++) { await new Promise((resolve) => process.nextTick(resolve)); } - expect(process.exitCode).toBe(1); + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); }); - it('sIGINT handler calls shutdown and sets exitCode on failure', async () => { + it('triggers shutdown when SIGINT handler is called', async () => { const wallet = createMockWallet(); mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); - (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); mockStartRpcSocketServer.mockResolvedValue(handle); const onSpy = jest.spyOn(process, 'on'); @@ -292,7 +306,49 @@ describe('daemon-entry', () => { await new Promise((resolve) => process.nextTick(resolve)); } - expect(process.exitCode).toBe(1); + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); + }); + + it('shutdown still calls wallet.destroy when handle.close fails', async () => { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + await onShutdown(); + + expect(wallet.destroy).toHaveBeenCalled(); + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('handle.close() failed'), + ); + }); + + it('shutdown logs wallet.destroy failure', async () => { + const wallet = createMockWallet(); + (wallet.destroy as jest.Mock).mockRejectedValue( + new Error('destroy failed'), + ); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + await onShutdown(); + + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('wallet.destroy() failed'), + ); }); it('handles rm rejection during shutdown cleanup gracefully', async () => { diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 591f23c5d89..97d5c4eac3b 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -1,11 +1,11 @@ import type { Json } from '@metamask/utils'; -import { appendFileSync, mkdirSync } from 'node:fs'; -import { rm, writeFile } from 'node:fs/promises'; +import { mkdirSync } from 'node:fs'; +import { appendFile, rm, writeFile } from 'node:fs/promises'; import { getDaemonPaths } from './paths'; import { startRpcSocketServer } from './rpc-socket-server'; import type { RpcSocketServerHandle } from './rpc-socket-server'; -import type { RpcHandlerMap } from './types'; +import type { DaemonStatusInfo, RpcHandlerMap } from './types'; import { createWallet } from './wallet-factory'; const startTime = Date.now(); @@ -54,7 +54,7 @@ async function main(): Promise { const wallet = await createWallet({ infuraProjectId, password, srp }); const handlers: RpcHandlerMap = { - getStatus: async () => ({ + getStatus: async (): Promise => ({ pid: process.pid, uptime: Math.floor((Date.now() - startTime) / 1000), }), @@ -84,10 +84,12 @@ async function main(): Promise { } catch (error) { try { await wallet.destroy(); - } catch { - // Best-effort cleanup. + } catch (destroyError) { + log(`wallet.destroy() failed during cleanup: ${String(destroyError)}`); } - await rm(pidPath, { force: true }).catch(() => undefined); + await rm(pidPath, { force: true }).catch((rmError: unknown) => { + log(`Failed to remove PID file during cleanup: ${String(rmError)}`); + }); throw error; } @@ -107,23 +109,32 @@ async function main(): Promise { shutdownPromise = (async (): Promise => { try { await handle.close(); + } catch (closeError) { + log(`handle.close() failed: ${String(closeError)}`); + } + try { await wallet.destroy(); - } finally { - await Promise.all([ - rm(pidPath, { force: true }).catch(() => undefined), - rm(socketPath, { force: true }).catch(() => undefined), - ]); + } catch (destroyError) { + log(`wallet.destroy() failed: ${String(destroyError)}`); } + await Promise.all([ + rm(pidPath, { force: true }).catch((rmError: unknown) => { + log(`Failed to remove PID file: ${String(rmError)}`); + }), + rm(socketPath, { force: true }).catch((rmError: unknown) => { + log(`Failed to remove socket file: ${String(rmError)}`); + }), + ]); })(); } return shutdownPromise; } process.on('SIGTERM', () => { - shutdown('SIGTERM').catch(() => (process.exitCode = 1)); + shutdown('SIGTERM').catch(() => undefined); }); process.on('SIGINT', () => { - shutdown('SIGINT').catch(() => (process.exitCode = 1)); + shutdown('SIGINT').catch(() => undefined); }); } @@ -136,6 +147,8 @@ async function main(): Promise { function makeLogger(logPath: string): (message: string) => void { return (message: string): void => { const line = `[${new Date().toISOString()}] ${message}\n`; - appendFileSync(logPath, line); + appendFile(logPath, line).catch((error: unknown) => { + process.stderr.write(`[log write failed: ${String(error)}] ${message}\n`); + }); }; } diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts index a8d74d96124..2e42e7ae065 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -3,19 +3,21 @@ import { existsSync } from 'node:fs'; import { pingDaemon } from './daemon-client'; import { ensureDaemon } from './daemon-spawn'; +import { getDaemonPaths } from './paths'; import type { DaemonSpawnConfig } from './types'; jest.mock('node:child_process'); jest.mock('node:fs'); jest.mock('./daemon-client'); +jest.mock('./paths'); const mockSpawn = jest.mocked(spawn); const mockExistsSync = jest.mocked(existsSync); const mockPingDaemon = jest.mocked(pingDaemon); +const mockGetDaemonPaths = jest.mocked(getDaemonPaths); const CONFIG: DaemonSpawnConfig = { dataDir: '/tmp/data', - socketPath: '/tmp/test.sock', infuraProjectId: 'test-key', password: 'test-pass', srp: 'test test test test test test test test test test test ball', @@ -25,8 +27,14 @@ const CONFIG: DaemonSpawnConfig = { describe('ensureDaemon', () => { beforeEach(() => { jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockGetDaemonPaths.mockReturnValue({ + socketPath: '/tmp/test.sock', + pidPath: '/tmp/test.pid', + logPath: '/tmp/test.log', + }); mockSpawn.mockReturnValue({ unref: jest.fn(), + on: jest.fn(), } as never); }); @@ -51,7 +59,6 @@ describe('ensureDaemon', () => { stdio: 'ignore', env: expect.objectContaining({ MM_DAEMON_DATA_DIR: '/tmp/data', - MM_DAEMON_SOCKET_PATH: '/tmp/test.sock', INFURA_PROJECT_ID: 'test-key', MM_WALLET_PASSWORD: 'test-pass', MM_WALLET_SRP: @@ -118,14 +125,38 @@ describe('ensureDaemon', () => { jest.useRealTimers(); }); - it('calls unref on spawned child', async () => { + it('calls unref on spawned child and registers error handler', async () => { const unref = jest.fn(); + const on = jest.fn(); mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mockExistsSync.mockReturnValue(true); - mockSpawn.mockReturnValue({ unref } as never); + mockSpawn.mockReturnValue({ unref, on } as never); await ensureDaemon(CONFIG); expect(unref).toHaveBeenCalled(); + expect(on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it('writes spawn errors to stderr', async () => { + const unref = jest.fn(); + let errorHandler: ((error: Error) => void) | undefined; + const on = jest.fn( + (event: string, handler: (error: Error) => void): void => { + if (event === 'error') { + errorHandler = handler; + } + }, + ); + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + mockSpawn.mockReturnValue({ unref, on } as never); + + await ensureDaemon(CONFIG); + errorHandler?.(new Error('spawn ENOENT')); + + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Failed to spawn daemon process'), + ); }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index 75d1611b8a9..5a63815d5d7 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { pingDaemon } from './daemon-client'; +import { getDaemonPaths } from './paths'; import type { DaemonSpawnConfig } from './types'; const POLL_INTERVAL_MS = 100; @@ -15,7 +16,7 @@ const MAX_POLLS = 300; // 30 seconds * @param config - Spawn configuration. */ export async function ensureDaemon(config: DaemonSpawnConfig): Promise { - const { socketPath } = config; + const { socketPath } = getDaemonPaths(config.dataDir); if (await pingDaemon(socketPath)) { return; } @@ -30,12 +31,14 @@ export async function ensureDaemon(config: DaemonSpawnConfig): Promise { env: { ...process.env, MM_DAEMON_DATA_DIR: config.dataDir, - MM_DAEMON_SOCKET_PATH: socketPath, INFURA_PROJECT_ID: config.infuraProjectId, MM_WALLET_PASSWORD: config.password, MM_WALLET_SRP: config.srp, }, }); + child.on('error', (error) => { + process.stderr.write(`Failed to spawn daemon process: ${String(error)}\n`); + }); child.unref(); for (let i = 0; i < MAX_POLLS; i++) { diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts index 61e68f207d0..29bc128640b 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -108,8 +108,10 @@ describe('startRpcSocketServer', () => { expect(mockUnlink).toHaveBeenCalledWith('/tmp/test.sock'); }); - it('ignores unlink errors for missing files', async () => { - mockUnlink.mockRejectedValue(new Error('ENOENT')); + it('ignores ENOENT unlink errors for missing files', async () => { + mockUnlink.mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); createMockServer(); const handle = await startRpcSocketServer({ @@ -119,6 +121,20 @@ describe('startRpcSocketServer', () => { expect(handle).toBeDefined(); }); + it('propagates non-ENOENT unlink errors', async () => { + mockUnlink.mockRejectedValue( + Object.assign(new Error('EACCES'), { code: 'EACCES' }), + ); + createMockServer(); + + await expect( + startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }), + ).rejects.toThrow('EACCES'); + }); + it('returns a handle with close()', async () => { const { server } = createMockServer(); const handle = await startRpcSocketServer({ @@ -345,8 +361,11 @@ describe('startRpcSocketServer', () => { jest.useRealTimers(); }); - it('handles onShutdown rejection gracefully', async () => { + it('handles onShutdown rejection and logs to stderr', async () => { jest.useFakeTimers(); + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); const { simulateConnection } = createMockServer(); const onShutdown = jest.fn().mockRejectedValue(new Error('shutdown err')); @@ -365,7 +384,11 @@ describe('startRpcSocketServer', () => { expect(getResponse(socket).result).toStrictEqual({ status: 'shutting down', }); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('onShutdown callback failed'), + ); jest.useRealTimers(); + stderrSpy.mockRestore(); }); it('responds to shutdown even without onShutdown callback', async () => { @@ -441,7 +464,10 @@ describe('startRpcSocketServer', () => { expect(getResponse(socket).result).toBe('ok'); }); - it('ignores socket errors', async () => { + it('silently ignores EPIPE and ECONNRESET socket errors', async () => { + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); const { simulateConnection } = createMockServer(); await startRpcSocketServer({ socketPath: '/tmp/test.sock', @@ -451,10 +477,41 @@ describe('startRpcSocketServer', () => { const socket = createMockSocket(); simulateConnection(socket); - // Should not throw - expect(() => - socket.emit('error', new Error('broken pipe')), - ).not.toThrow(); + socket.emit( + 'error', + Object.assign(new Error('broken pipe'), { code: 'EPIPE' }), + ); + socket.emit( + 'error', + Object.assign(new Error('reset'), { code: 'ECONNRESET' }), + ); + + expect(stderrSpy).not.toHaveBeenCalled(); + stderrSpy.mockRestore(); + }); + + it('logs unexpected socket errors to stderr', async () => { + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + socket.emit( + 'error', + Object.assign(new Error('unexpected'), { code: 'ENOMEM' }), + ); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Unexpected socket error'), + ); + stderrSpy.mockRestore(); }); it('sends internal error when response serialization fails', async () => { diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 2f4a59c9fe0..5e00290e745 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -1,10 +1,12 @@ import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcResponse } from '@metamask/utils'; import { hasProperty } from '@metamask/utils'; import { unlink } from 'node:fs/promises'; import { createServer } from 'node:net'; import type { Server } from 'node:net'; import type { RpcHandlerMap } from './types'; +import { isErrorWithCode } from './utils'; const CONNECTION_TIMEOUT_MS = 30_000; @@ -95,8 +97,12 @@ export async function startRpcSocketServer({ }; socket.on('data', onData); - socket.on('error', () => { - // Ignore client socket errors (e.g. broken pipe from probe connections). + socket.on('error', (socketError: NodeJS.ErrnoException) => { + const { code } = socketError; + if (code === 'EPIPE' || code === 'ECONNRESET') { + return; // Expected during probe/disconnect. + } + process.stderr.write(`Unexpected socket error: ${String(socketError)}\n`); }); }); @@ -129,8 +135,9 @@ async function handleRequest( handlers: RpcHandlerMap, line: string, onShutdown?: () => Promise, -): Promise> { - let id: unknown = null; +): Promise { + type JsonRpcId = string | number | null; + let id: JsonRpcId = null; let request: { id?: unknown; method?: string; params?: unknown }; try { @@ -143,7 +150,7 @@ async function handleRequest( }; } - id = request.id ?? null; + id = (request.id ?? null) as JsonRpcId; try { const { method } = request; @@ -162,8 +169,10 @@ async function handleRequest( if (method === 'shutdown') { if (onShutdown) { setTimeout(() => { - onShutdown().catch(() => { - // Best-effort shutdown. + onShutdown().catch((error: unknown) => { + process.stderr.write( + `onShutdown callback failed: ${String(error)}\n`, + ); }); }, 0); } @@ -225,8 +234,10 @@ function isRpcError( async function listen(server: Server, socketPath: string): Promise { try { await unlink(socketPath); - } catch { - // Ignore — file may not exist. + } catch (error) { + if (!isErrorWithCode(error, 'ENOENT')) { + throw error; + } } return new Promise((resolve, reject) => { diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts index d03fd6cc115..48e276eeed7 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.test.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -64,6 +64,8 @@ describe('stopDaemon', () => { }); expect(log).toHaveBeenCalledWith('Stopping daemon...'); expect(log).toHaveBeenCalledWith('Daemon stopped.'); + expect(mockRm).toHaveBeenCalledWith('/tmp/test.pid', { force: true }); + expect(mockRm).toHaveBeenCalledWith('/tmp/test.sock', { force: true }); }); it('falls through to SIGTERM when graceful shutdown times out', async () => { diff --git a/packages/wallet-cli/src/daemon/stop-daemon.ts b/packages/wallet-cli/src/daemon/stop-daemon.ts index 24a47f158cf..dc5f0c27901 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.ts @@ -36,8 +36,8 @@ export async function stopDaemon( if (socketResponsive) { try { await sendCommand({ socketPath, method: 'shutdown' }); - } catch { - // Socket became unresponsive. + } catch (error) { + log?.(`Graceful shutdown request failed: ${String(error)}`); } stopped = await waitFor(async () => !(await pingDaemon(socketPath)), 5_000); } @@ -50,8 +50,8 @@ export async function stopDaemon( } else { stopped = true; // Process already gone (ESRCH). } - } catch { - // Permission error — fall through to next strategy. + } catch (error) { + log?.(`SIGTERM failed: ${String(error)}`); } } @@ -63,13 +63,16 @@ export async function stopDaemon( } else { stopped = true; // Process already gone (ESRCH). } - } catch { - // Permission error — cannot kill process. + } catch (error) { + log?.(`SIGKILL failed: ${String(error)}`); } } if (stopped) { - await rm(pidPath, { force: true }); + await Promise.all([ + rm(pidPath, { force: true }), + rm(socketPath, { force: true }), + ]); log?.('Daemon stopped.'); } diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index 714f6e81a93..4d2793cb23c 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -2,8 +2,10 @@ import type { Json } from '@metamask/utils'; /** * A function that handles a JSON-RPC method call. + * + * The `params` argument will be `null` if the client did not provide params. */ -export type RpcHandler = (params: Json) => Promise; +export type RpcHandler = (params: Json) => Promise; /** * A map of RPC method names to their handler functions. @@ -19,12 +21,19 @@ export type DaemonPaths = { logPath: string; }; +/** + * Status information returned by the daemon's `getStatus` RPC method. + */ +export type DaemonStatusInfo = { + pid: number; + uptime: number; +}; + /** * Configuration passed to the daemon spawner. */ export type DaemonSpawnConfig = { dataDir: string; - socketPath: string; infuraProjectId: string; password: string; srp: string; From 2477f978a667aca73e4f3c30992551f3094ad727 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:20:37 -0700 Subject: [PATCH 18/32] feat(wallet-cli): Add confirmation prompt to daemon purge command Replace the required --force flag with an interactive y/N confirmation prompt using @inquirer/confirm. The --force flag now skips the prompt instead of being mandatory. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wallet-cli/package.json | 1 + .../wallet-cli/src/commands/daemon/purge.ts | 22 +++++-- yarn.lock | 62 +++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 62674f030cb..ed62fb7439f 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -47,6 +47,7 @@ "topicSeparator": " " }, "dependencies": { + "@inquirer/confirm": "^6.0.11", "@metamask/remote-feature-flag-controller": "^4.2.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.9.0", diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts index cb4e969fff0..2a09db3ee9c 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -8,18 +8,32 @@ export default class DaemonPurge extends Command { static override description = 'Stop the daemon and delete all daemon state files'; - static override examples = ['<%= config.bin %> daemon purge --force']; + static override examples = [ + '<%= config.bin %> daemon purge', + '<%= config.bin %> daemon purge --force', + ]; static override flags = { force: Flags.boolean({ char: 'f', - description: 'Required to confirm purge', - required: true, + description: 'Skip confirmation prompt', }), }; public async run(): Promise { - await this.parse(DaemonPurge); + const { flags } = await this.parse(DaemonPurge); + + if (!flags.force) { + const { default: confirm } = await import('@inquirer/confirm'); + const confirmed = await confirm({ + message: 'This will stop the daemon and delete all state. Continue?', + default: false, + }); + if (!confirmed) { + this.log('Aborted.'); + return; + } + } const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); diff --git a/yarn.lock b/yarn.lock index 3adc538f907..aa59c5ec1ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1799,6 +1799,13 @@ __metadata: languageName: node linkType: hard +"@inquirer/ansi@npm:^2.0.5": + version: 2.0.5 + resolution: "@inquirer/ansi@npm:2.0.5" + checksum: 10/482f8a606885ee0377a60eb5e9b303ae75fcfb2c6250819be348047c89e4e01a25feef369d3646dec7ba17e38cd5cc08271db6db21c401be315b3ada749e6b53 + languageName: node + linkType: hard + "@inquirer/checkbox@npm:^5.0.5": version: 5.0.7 resolution: "@inquirer/checkbox@npm:5.0.7" @@ -1827,6 +1834,21 @@ __metadata: languageName: node linkType: hard +"@inquirer/confirm@npm:^6.0.11": + version: 6.0.11 + resolution: "@inquirer/confirm@npm:6.0.11" + dependencies: + "@inquirer/core": "npm:^11.1.8" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/f51ead4a6a68ac585257e66bbe8196a6b7aec1956b12038827a2d03a509b9db8e0ece97d4b92033259090de33d9aefd0cff288cd4dce6f472d927ef8fe9302f5 + languageName: node + linkType: hard + "@inquirer/confirm@npm:^6.0.5": version: 6.0.7 resolution: "@inquirer/confirm@npm:6.0.7" @@ -1879,6 +1901,26 @@ __metadata: languageName: node linkType: hard +"@inquirer/core@npm:^11.1.8": + version: 11.1.8 + resolution: "@inquirer/core@npm:11.1.8" + dependencies: + "@inquirer/ansi": "npm:^2.0.5" + "@inquirer/figures": "npm:^2.0.5" + "@inquirer/type": "npm:^4.0.5" + cli-width: "npm:^4.1.0" + fast-wrap-ansi: "npm:^0.2.0" + mute-stream: "npm:^3.0.0" + signal-exit: "npm:^4.1.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/e034f637ea9c12c2aaf8f5b128611f9d72976b50cf387f1207e0459342924c64f2de7e675e2b86616c44daf05700c4764f83e7ca1417aa41ed3d29d458062218 + languageName: node + linkType: hard + "@inquirer/editor@npm:^5.0.5": version: 5.0.7 resolution: "@inquirer/editor@npm:5.0.7" @@ -1932,6 +1974,13 @@ __metadata: languageName: node linkType: hard +"@inquirer/figures@npm:^2.0.5": + version: 2.0.5 + resolution: "@inquirer/figures@npm:2.0.5" + checksum: 10/e4d09c11a75206578abcfd8fc69b0f54cff7a853826696df5b3a45ed24ebc5c82e8998f1e9fa42119de848e6a0a526a6ac476053800413637bf6d21c2116cc60 + languageName: node + linkType: hard + "@inquirer/input@npm:^0.0.15-alpha.0": version: 0.0.15-alpha.0 resolution: "@inquirer/input@npm:0.0.15-alpha.0" @@ -2071,6 +2120,18 @@ __metadata: languageName: node linkType: hard +"@inquirer/type@npm:^4.0.5": + version: 4.0.5 + resolution: "@inquirer/type@npm:4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/83d15e11cc0586373070e8c262f69b1d1e4a6c72f58b3afb3d163479309f5a9bb584320eec2d85474506fb845a114e2c50010758fcf3af56c93293d579f76333 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -5831,6 +5892,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" dependencies: + "@inquirer/confirm": "npm:^6.0.11" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/remote-feature-flag-controller": "npm:^4.2.0" "@metamask/rpc-errors": "npm:^7.0.2" From f923ed9cecda4bcd35f7c5a8b846aef4f3eda135 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:26:43 -0700 Subject: [PATCH 19/32] chore: lint dependencies --- yarn.lock | 71 ++++--------------------------------------------------- 1 file changed, 5 insertions(+), 66 deletions(-) diff --git a/yarn.lock b/yarn.lock index aa59c5ec1ec..24bf0842516 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1792,14 +1792,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/ansi@npm:^2.0.3": - version: 2.0.3 - resolution: "@inquirer/ansi@npm:2.0.3" - checksum: 10/846bf48acc4a89e62c2be49af74c014311e5a575a46875fe3066c28ac241671132fb16518e859bd63db6a2be326a6a7160c9a79f60013b1a2c6a1c1d620a98ac - languageName: node - linkType: hard - -"@inquirer/ansi@npm:^2.0.5": +"@inquirer/ansi@npm:^2.0.3, @inquirer/ansi@npm:^2.0.5": version: 2.0.5 resolution: "@inquirer/ansi@npm:2.0.5" checksum: 10/482f8a606885ee0377a60eb5e9b303ae75fcfb2c6250819be348047c89e4e01a25feef369d3646dec7ba17e38cd5cc08271db6db21c401be315b3ada749e6b53 @@ -1834,7 +1827,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/confirm@npm:^6.0.11": +"@inquirer/confirm@npm:^6.0.11, @inquirer/confirm@npm:^6.0.5": version: 6.0.11 resolution: "@inquirer/confirm@npm:6.0.11" dependencies: @@ -1849,21 +1842,6 @@ __metadata: languageName: node linkType: hard -"@inquirer/confirm@npm:^6.0.5": - version: 6.0.7 - resolution: "@inquirer/confirm@npm:6.0.7" - dependencies: - "@inquirer/core": "npm:^11.1.4" - "@inquirer/type": "npm:^4.0.3" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/c7da13527dd46f09515f68f1a809791eb8fc349422a39d9fd4c4254082edff3f8c9ffda4c86d10f1b80ebdbed34d2bc2c86c2275096be3d4559a35a88955da41 - languageName: node - linkType: hard - "@inquirer/core@npm:^0.0.15-alpha.0": version: 0.0.15-alpha.0 resolution: "@inquirer/core@npm:0.0.15-alpha.0" @@ -1881,27 +1859,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/core@npm:^11.1.4": - version: 11.1.4 - resolution: "@inquirer/core@npm:11.1.4" - dependencies: - "@inquirer/ansi": "npm:^2.0.3" - "@inquirer/figures": "npm:^2.0.3" - "@inquirer/type": "npm:^4.0.3" - cli-width: "npm:^4.1.0" - fast-wrap-ansi: "npm:^0.2.0" - mute-stream: "npm:^3.0.0" - signal-exit: "npm:^4.1.0" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/e022de7f3b9b65b7ae98b5316b205ec59c84294936fd63f57b37673f2d334dd8b762c50e8d3edf59caac72d6bed86e533fa88e87f3e271a3275c0a1f6acf0271 - languageName: node - linkType: hard - -"@inquirer/core@npm:^11.1.8": +"@inquirer/core@npm:^11.1.4, @inquirer/core@npm:^11.1.8": version: 11.1.8 resolution: "@inquirer/core@npm:11.1.8" dependencies: @@ -1967,14 +1925,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/figures@npm:^2.0.3": - version: 2.0.3 - resolution: "@inquirer/figures@npm:2.0.3" - checksum: 10/d1496081e28e8fb5f50790fdbf7fb5f437933de557092a3eaac7f290190f9597ff9d75693bbb6d94e3da71b4dae567f2e88d3c54557b280b7b832219ff26e072 - languageName: node - linkType: hard - -"@inquirer/figures@npm:^2.0.5": +"@inquirer/figures@npm:^2.0.3, @inquirer/figures@npm:^2.0.5": version: 2.0.5 resolution: "@inquirer/figures@npm:2.0.5" checksum: 10/e4d09c11a75206578abcfd8fc69b0f54cff7a853826696df5b3a45ed24ebc5c82e8998f1e9fa42119de848e6a0a526a6ac476053800413637bf6d21c2116cc60 @@ -2108,19 +2059,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/type@npm:^4.0.3": - version: 4.0.3 - resolution: "@inquirer/type@npm:4.0.3" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/93166fc35dbb597067341c125090a51b1c8d0d57308ec14ad601727513d0aefa33643cff28aa52e7e6796a866bb2f60b4e49820774970110fe8454a276ac7c4c - languageName: node - linkType: hard - -"@inquirer/type@npm:^4.0.5": +"@inquirer/type@npm:^4.0.3, @inquirer/type@npm:^4.0.5": version: 4.0.5 resolution: "@inquirer/type@npm:4.0.5" peerDependencies: From 803662cfef34ec887adc63dde6cfdc9552c9b3b1 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:45:56 -0700 Subject: [PATCH 20/32] chore: Fix wallet-cli test coverage --- packages/wallet-cli/src/daemon/daemon-entry.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 97d5c4eac3b..2ae410462ac 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -131,9 +131,11 @@ async function main(): Promise { } process.on('SIGTERM', () => { + /* istanbul ignore next */ shutdown('SIGTERM').catch(() => undefined); }); process.on('SIGINT', () => { + /* istanbul ignore next */ shutdown('SIGINT').catch(() => undefined); }); } From 41a23977ccf932827ec9b02fb80f3bbc671e4e80 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 4 May 2026 11:03:50 -0700 Subject: [PATCH 21/32] chore: Format --- packages/wallet-cli/package.json | 34 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index ed62fb7439f..5612e5c8c1b 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -3,22 +3,18 @@ "version": "0.0.0", "description": "The CLI of @metamask/wallet", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/wallet-cli#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "sideEffects": false, - "exports": { - "./package.json": "./package.json" - }, "bin": { "mm": "./bin/run.mjs" }, @@ -26,6 +22,14 @@ "bin/", "dist/" ], + "sideEffects": false, + "exports": { + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", @@ -40,12 +44,6 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, - "oclif": { - "bin": "mm", - "commands": "./dist/commands", - "dirname": "mm", - "topicSeparator": " " - }, "dependencies": { "@inquirer/confirm": "^6.0.11", "@metamask/remote-feature-flag-controller": "^4.2.0", @@ -66,11 +64,13 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, + "oclif": { + "bin": "mm", + "commands": "./dist/commands", + "dirname": "mm", + "topicSeparator": " " + }, "engines": { "node": "^18.18 || >=20" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" } } From ed3e910d9b39a1b009fa0655b0ed1fc77a080404 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 4 May 2026 11:08:48 -0700 Subject: [PATCH 22/32] chore(teams.json): Add ocap kernel to wallet-cli --- teams.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/teams.json b/teams.json index 3181ba1cf30..544df072bd8 100644 --- a/teams.json +++ b/teams.json @@ -83,5 +83,5 @@ "metamask/money-account-upgrade-controller": "team-earn", "metamask/snap-account-service": "team-accounts-framework", "metamask/wallet": "team-core-platform", - "metamask/wallet-cli": "team-core-platform" + "metamask/wallet-cli": "team-core-platform,team-ocap-kernel" } From 635ac142dc47a89e5de3de0234d0abc986f13a93 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 4 May 2026 11:19:04 -0700 Subject: [PATCH 23/32] chore: fix constraints --- packages/wallet-cli/package.json | 2 +- yarn.lock | 19 ++----------------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 5612e5c8c1b..63d3e35cb54 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -53,7 +53,7 @@ "@oclif/core": "^4.10.5" }, "devDependencies": { - "@metamask/auto-changelog": "^3.4.4", + "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", diff --git a/yarn.lock b/yarn.lock index 24bf0842516..21de7e84b94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2947,21 +2947,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/auto-changelog@npm:^3.4.4": - version: 3.4.4 - resolution: "@metamask/auto-changelog@npm:3.4.4" - dependencies: - diff: "npm:^5.0.0" - execa: "npm:^5.1.1" - prettier: "npm:^2.8.8" - semver: "npm:^7.3.5" - yargs: "npm:^17.0.1" - bin: - auto-changelog: dist/cli.js - checksum: 10/70e98529a153ebeab10410dbc3f567014999f77ed82f2b52f1b36501b28a4e3614c809a90c89600a739d7710595bfecc30e2260410e6afac7539f8db65a48f2c - languageName: node - linkType: hard - "@metamask/auto-changelog@npm:^6.1.0": version: 6.1.0 resolution: "@metamask/auto-changelog@npm:6.1.0" @@ -5832,7 +5817,7 @@ __metadata: resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" dependencies: "@inquirer/confirm": "npm:^6.0.11" - "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" @@ -13623,7 +13608,7 @@ __metadata: languageName: node linkType: hard -"prettier-2@npm:prettier@^2.8.8, prettier@npm:^2.8.8": +"prettier-2@npm:prettier@^2.8.8": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: From a091767d1d30d91c253f0b12f4ab2a8959be88bf Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 4 May 2026 11:19:22 -0700 Subject: [PATCH 24/32] refactor(wallet-cli): Fix Wallet constructor use --- .../wallet-cli/src/daemon/wallet-factory.ts | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 7707d73a672..3152a227ef3 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -25,21 +25,19 @@ export async function createWallet({ srp: string; }): Promise { const wallet = new Wallet({ - options: { - infuraProjectId, - clientVersion: '0.0.0', - // TODO: Implement showApprovalRequest - showApprovalRequest: (): undefined => undefined, - clientConfigApiService: new ClientConfigApiService({ - fetch: globalThis.fetch, - config: { - client: ClientType.Extension, - distribution: DistributionType.Main, - environment: EnvironmentType.Production, - }, - }), - getMetaMetricsId: (): string => 'cli', - }, + infuraProjectId, + clientVersion: '0.0.0', + // TODO: Implement showApprovalRequest + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'cli', }); await importSecretRecoveryPhrase(wallet, password, srp); From 55e14983dfcf8a4f0a809d57b223b659b78d6e16 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 11 May 2026 20:03:48 +0200 Subject: [PATCH 25/32] feat(wallet-cli): Move sqlite persistence from wallet to wallet-cli The `@metamask/wallet` package is meant to be platform-agnostic, so the Node-only `better-sqlite3` persistence layer moves to the CLI together with its tests. `createWallet` now opens a `KeyValueStore` at `/wallet.db`, hydrates state via `loadState`, wires `subscribeToChanges`, and only imports the SRP on first run (detected by absence of a persisted KeyringController vault). On any post-construction failure the wallet is destroyed before the store closes, and first-run failures also remove the on-disk database so a retry can't latch onto an orphaned partial vault. Closes #8682 Co-Authored-By: Claude Opus 4.7 (1M context) --- eslint-suppressions.json | 2 +- packages/wallet-cli/CHANGELOG.md | 7 + packages/wallet-cli/README.md | 18 ++ packages/wallet-cli/package.json | 7 +- .../wallet-cli/scripts/install-binaries.sh | 23 ++ .../src/daemon/daemon-entry.test.ts | 166 +++++++---- .../wallet-cli/src/daemon/daemon-entry.ts | 18 +- packages/wallet-cli/src/daemon/paths.test.ts | 1 + packages/wallet-cli/src/daemon/paths.ts | 3 +- packages/wallet-cli/src/daemon/types.ts | 1 + .../src/daemon/wallet-factory.test.ts | 282 +++++++++++++++++- .../wallet-cli/src/daemon/wallet-factory.ts | 124 ++++++-- .../src/persistence/KeyValueStore.test.ts | 0 .../src/persistence/KeyValueStore.ts | 0 .../src/persistence/persistence.test.ts | 4 +- .../src/persistence/persistence.ts | 10 +- packages/wallet/CHANGELOG.md | 7 + packages/wallet/README.md | 10 - packages/wallet/package.json | 14 +- packages/wallet/scripts/install-binaries.sh | 16 +- packages/wallet/src/persistence/index.ts | 2 - yarn.config.cjs | 7 +- 22 files changed, 582 insertions(+), 140 deletions(-) create mode 100755 packages/wallet-cli/scripts/install-binaries.sh rename packages/{wallet => wallet-cli}/src/persistence/KeyValueStore.test.ts (100%) rename packages/{wallet => wallet-cli}/src/persistence/KeyValueStore.ts (100%) rename packages/{wallet => wallet-cli}/src/persistence/persistence.test.ts (99%) rename packages/{wallet => wallet-cli}/src/persistence/persistence.ts (98%) delete mode 100644 packages/wallet/src/persistence/index.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 558c8628fcd..f6cedf3be4e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2351,7 +2351,7 @@ }, "packages/wallet/src/initialization/instances/accounts-controller.ts": { "no-restricted-syntax": { - "count": 2 + "count": 1 } }, "packages/wallet/src/initialization/instances/transaction-controller.ts": { diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index b518709c7b8..4567905df49 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -7,4 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Persist controller state to a `better-sqlite3` key-value store inside the daemon's data directory ([#8682](https://github.com/MetaMask/core/issues/8682)) + - The persistence layer (`KeyValueStore`, `loadState`, `subscribeToChanges`) moves here from `@metamask/wallet`. + - The daemon writes to `/wallet.db`, hydrates the `Wallet` from the store on startup, subscribes to the `:stateChanged` events of controllers that declare persist-flagged state for write-through persistence, and closes the store during shutdown. + - On subsequent runs the daemon reuses the persisted KeyringController vault instead of re-importing the supplied SRP. The wallet still starts locked; unlock is the caller's responsibility. + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/README.md b/packages/wallet-cli/README.md index 1de99a32fa7..3c1bf905272 100644 --- a/packages/wallet-cli/README.md +++ b/packages/wallet-cli/README.md @@ -10,6 +10,24 @@ or `npm install @metamask/wallet-cli` +## Troubleshooting + +### Rebuilding `better-sqlite3` + +This package depends on `better-sqlite3`, which ships a native C addon. The monorepo runs Yarn with `enableScripts: false`, so the addon is **not** fetched automatically during `yarn install`. Instead, the package's `test:prepare` script (`scripts/install-binaries.sh`) downloads the matching prebuild on demand the first time you run tests. + +If you switch Node versions or branches and the binding is missing, re-run: + +```sh +yarn workspace @metamask/wallet-cli run test:prepare +``` + +Or invoke `prebuild-install` directly from the workspace root: + +```sh +cd node_modules/better-sqlite3 && node ../.bin/prebuild-install +``` + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 63d3e35cb54..f5d2c9a7412 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -39,7 +39,8 @@ "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", "since-latest-release": "../../scripts/since-latest-release.sh", - "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:prepare": "./scripts/install-binaries.sh", + "test": "yarn test:prepare && NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" @@ -50,11 +51,13 @@ "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.9.0", "@metamask/wallet": "^0.0.0", - "@oclif/core": "^4.10.5" + "@oclif/core": "^4.10.5", + "better-sqlite3": "^12.9.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", diff --git a/packages/wallet-cli/scripts/install-binaries.sh b/packages/wallet-cli/scripts/install-binaries.sh new file mode 100755 index 00000000000..09374356a22 --- /dev/null +++ b/packages/wallet-cli/scripts/install-binaries.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +# Pin cwd to the package root so all paths are predictable regardless of how +# this script is invoked. Also derive the monorepo root (two levels up). +PACKAGE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MONOREPO_ROOT="$(cd "${PACKAGE_ROOT}/../.." && pwd)" +cd "${PACKAGE_ROOT}" + +# Install the better-sqlite3 native addon if missing. Yarn has +# `enableScripts: false` globally, so install scripts never run during +# `yarn install` and the addon may be absent from the filesystem. Invoke the +# prebuild-install binary directly to fetch a matching prebuild for the active +# Node version and platform. +BETTER_SQLITE3_DIR="${MONOREPO_ROOT}/node_modules/better-sqlite3" +if [ ! -f "${BETTER_SQLITE3_DIR}/build/Release/better_sqlite3.node" ]; then + ( + cd "${BETTER_SQLITE3_DIR}" + "${MONOREPO_ROOT}/node_modules/.bin/prebuild-install" + ) +fi diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 2e0e57193a4..97d6466fee8 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -22,17 +22,24 @@ const mockCreateWallet = jest.mocked(createWallet); const ORIGINAL_ENV = process.env; +type MockCreateWalletResult = Awaited>; + /** - * Create a mock wallet. + * Create a mock createWallet result with a mocked wallet and store. * - * @returns A mock wallet object. + * @returns A mock createWallet result. */ -function createMockWallet(): Awaited> { +function createMockWallet(): MockCreateWalletResult { return { - messenger: { call: jest.fn() } as never, - state: {} as never, - destroy: jest.fn().mockResolvedValue(undefined), - } as unknown as Awaited>; + wallet: { + messenger: { call: jest.fn() }, + state: {}, + destroy: jest.fn().mockResolvedValue(undefined), + }, + store: { + close: jest.fn(), + }, + } as unknown as MockCreateWalletResult; } /** @@ -63,6 +70,7 @@ describe('daemon-entry', () => { socketPath: '/tmp/daemon.sock', pidPath: '/tmp/daemon.pid', logPath: '/tmp/daemon.log', + dbPath: '/tmp/wallet.db', }); mockWriteFile.mockResolvedValue(undefined); mockRm.mockResolvedValue(undefined); @@ -137,10 +145,8 @@ describe('daemon-entry', () => { }); it('creates data dir, wallet, server, and writes PID on successful startup', async () => { - const wallet = createMockWallet(); - mockCreateWallet.mockResolvedValue(wallet); - const handle = createMockHandle(); - mockStartRpcSocketServer.mockResolvedValue(handle); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); await importDaemonEntry(); @@ -148,6 +154,7 @@ describe('daemon-entry', () => { recursive: true, }); expect(mockCreateWallet).toHaveBeenCalledWith({ + databasePath: '/tmp/wallet.db', infuraProjectId: 'key', password: 'pass', srp: 'test test test test test test test test test test test ball', @@ -179,28 +186,48 @@ describe('daemon-entry', () => { ); }); - it('cleans up wallet and PID file when server fails to start', async () => { - const wallet = createMockWallet(); - mockCreateWallet.mockResolvedValue(wallet); + it('cleans up wallet, store, and PID file when server fails to start', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); await importDaemonEntry(); - expect(wallet.destroy).toHaveBeenCalled(); + expect(result.wallet.destroy).toHaveBeenCalled(); + expect(result.store.close).toHaveBeenCalled(); expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); expect(process.exitCode).toBe(1); }); - it('still cleans up PID when wallet.destroy fails during error cleanup', async () => { - const wallet = createMockWallet(); - (wallet.destroy as jest.Mock).mockRejectedValue( + it('still cleans up PID and store when wallet.destroy fails during error cleanup', async () => { + const result = createMockWallet(); + (result.wallet.destroy as jest.Mock).mockRejectedValue( new Error('destroy failed'), ); - mockCreateWallet.mockResolvedValue(wallet); + mockCreateWallet.mockResolvedValue(result); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + + await importDaemonEntry(); + + expect(result.store.close).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(process.exitCode).toBe(1); + }); + + it('logs and continues when store.close throws during error cleanup', async () => { + const result = createMockWallet(); + (result.store.close as jest.Mock).mockImplementation(() => { + throw new Error('close failed'); + }); + mockCreateWallet.mockResolvedValue(result); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); await importDaemonEntry(); + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('store.close() failed during cleanup'), + ); expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); expect(process.exitCode).toBe(1); }); @@ -267,8 +294,8 @@ describe('daemon-entry', () => { }); it('triggers shutdown when SIGTERM handler is called', async () => { - const wallet = createMockWallet(); - mockCreateWallet.mockResolvedValue(wallet); + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -285,12 +312,13 @@ describe('daemon-entry', () => { } expect(handle.close).toHaveBeenCalled(); - expect(wallet.destroy).toHaveBeenCalled(); + expect(result.wallet.destroy).toHaveBeenCalled(); + expect(result.store.close).toHaveBeenCalled(); }); it('triggers shutdown when SIGINT handler is called', async () => { - const wallet = createMockWallet(); - mockCreateWallet.mockResolvedValue(wallet); + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -307,12 +335,13 @@ describe('daemon-entry', () => { } expect(handle.close).toHaveBeenCalled(); - expect(wallet.destroy).toHaveBeenCalled(); + expect(result.wallet.destroy).toHaveBeenCalled(); + expect(result.store.close).toHaveBeenCalled(); }); it('shutdown still calls wallet.destroy when handle.close fails', async () => { - const wallet = createMockWallet(); - mockCreateWallet.mockResolvedValue(wallet); + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); const handle = createMockHandle(); (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -323,7 +352,7 @@ describe('daemon-entry', () => { const onShutdown = callArgs.onShutdown as () => Promise; await onShutdown(); - expect(wallet.destroy).toHaveBeenCalled(); + expect(result.wallet.destroy).toHaveBeenCalled(); expect(mockAppendFile).toHaveBeenCalledWith( '/tmp/daemon.log', expect.stringContaining('handle.close() failed'), @@ -331,11 +360,11 @@ describe('daemon-entry', () => { }); it('shutdown logs wallet.destroy failure', async () => { - const wallet = createMockWallet(); - (wallet.destroy as jest.Mock).mockRejectedValue( + const result = createMockWallet(); + (result.wallet.destroy as jest.Mock).mockRejectedValue( new Error('destroy failed'), ); - mockCreateWallet.mockResolvedValue(wallet); + mockCreateWallet.mockResolvedValue(result); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -351,9 +380,30 @@ describe('daemon-entry', () => { ); }); + it('shutdown logs store.close failure', async () => { + const result = createMockWallet(); + (result.store.close as jest.Mock).mockImplementation(() => { + throw new Error('close failed'); + }); + mockCreateWallet.mockResolvedValue(result); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + await onShutdown(); + + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('store.close() failed'), + ); + }); + it('handles rm rejection during shutdown cleanup gracefully', async () => { - const wallet = createMockWallet(); - mockCreateWallet.mockResolvedValue(wallet); + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); // rm rejects but cleanup should not fail @@ -367,12 +417,11 @@ describe('daemon-entry', () => { await onShutdown(); expect(handle.close).toHaveBeenCalled(); - expect(wallet.destroy).toHaveBeenCalled(); + expect(result.wallet.destroy).toHaveBeenCalled(); }); it('handles rm rejection in error cleanup path gracefully', async () => { - const wallet = createMockWallet(); - mockCreateWallet.mockResolvedValue(wallet); + mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); mockRm.mockRejectedValue(new Error('rm failed')); @@ -382,8 +431,8 @@ describe('daemon-entry', () => { }); it('onShutdown closes server and destroys wallet', async () => { - const wallet = createMockWallet(); - mockCreateWallet.mockResolvedValue(wallet); + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -396,7 +445,8 @@ describe('daemon-entry', () => { await onShutdown(); expect(handle.close).toHaveBeenCalled(); - expect(wallet.destroy).toHaveBeenCalled(); + expect(result.wallet.destroy).toHaveBeenCalled(); + expect(result.store.close).toHaveBeenCalled(); expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); }); @@ -405,14 +455,14 @@ describe('daemon-entry', () => { * Import the daemon entry and extract the `call` handler from the * handlers map, along with the mock wallet for assertions. * - * @returns The call handler function and mock wallet. + * @returns The call handler function and mock wallet result. */ async function setupCallHandler(): Promise<{ callHandler: (params: unknown) => Promise; - wallet: Awaited>; + result: MockCreateWalletResult; }> { - const wallet = createMockWallet(); - mockCreateWallet.mockResolvedValue(wallet); + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); await importDaemonEntry(); @@ -421,7 +471,7 @@ describe('daemon-entry', () => { const callHandler = callArgs.handlers.call as ( params: unknown, ) => Promise; - return { callHandler, wallet }; + return { callHandler, result }; } it('registers a call handler', async () => { @@ -435,23 +485,27 @@ describe('daemon-entry', () => { }); it('forwards action and args to messenger.call', async () => { - const { callHandler, wallet } = await setupCallHandler(); - const mockCall = wallet.messenger.call as jest.Mock; + const { callHandler, result } = await setupCallHandler(); + const mockCall = result.wallet.messenger.call as jest.Mock; mockCall.mockReturnValue({ accounts: [] }); - const result = await callHandler(['Controller:action', 'arg1', 'arg2']); + const callResult = await callHandler([ + 'Controller:action', + 'arg1', + 'arg2', + ]); expect(mockCall).toHaveBeenCalledWith( 'Controller:action', 'arg1', 'arg2', ); - expect(result).toStrictEqual({ accounts: [] }); + expect(callResult).toStrictEqual({ accounts: [] }); }); it('calls messenger.call with no extra args when only action is provided', async () => { - const { callHandler, wallet } = await setupCallHandler(); - const mockCall = wallet.messenger.call as jest.Mock; + const { callHandler, result } = await setupCallHandler(); + const mockCall = result.wallet.messenger.call as jest.Mock; mockCall.mockReturnValue('ok'); await callHandler(['Controller:action']); @@ -460,18 +514,18 @@ describe('daemon-entry', () => { }); it('awaits async messenger.call results', async () => { - const { callHandler, wallet } = await setupCallHandler(); - const mockCall = wallet.messenger.call as jest.Mock; + const { callHandler, result } = await setupCallHandler(); + const mockCall = result.wallet.messenger.call as jest.Mock; mockCall.mockResolvedValue({ async: true }); - const result = await callHandler(['Controller:asyncAction']); + const callResult = await callHandler(['Controller:asyncAction']); - expect(result).toStrictEqual({ async: true }); + expect(callResult).toStrictEqual({ async: true }); }); it('propagates errors thrown by messenger.call', async () => { - const { callHandler, wallet } = await setupCallHandler(); - const mockCall = wallet.messenger.call as jest.Mock; + const { callHandler, result } = await setupCallHandler(); + const mockCall = result.wallet.messenger.call as jest.Mock; mockCall.mockImplementation(() => { throw new Error('A handler for Unknown:action has not been registered'); }); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 2ae410462ac..cfe16be3840 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -45,13 +45,19 @@ async function main(): Promise { socketPath: defaultSocketPath, pidPath, logPath, + dbPath, } = getDaemonPaths(dataDir); const socketPath = process.env.MM_DAEMON_SOCKET_PATH ?? defaultSocketPath; const log = makeLogger(logPath); log('Starting daemon...'); - const wallet = await createWallet({ infuraProjectId, password, srp }); + const { wallet, store } = await createWallet({ + databasePath: dbPath, + infuraProjectId, + password, + srp, + }); const handlers: RpcHandlerMap = { getStatus: async (): Promise => ({ @@ -87,6 +93,11 @@ async function main(): Promise { } catch (destroyError) { log(`wallet.destroy() failed during cleanup: ${String(destroyError)}`); } + try { + store.close(); + } catch (closeError) { + log(`store.close() failed during cleanup: ${String(closeError)}`); + } await rm(pidPath, { force: true }).catch((rmError: unknown) => { log(`Failed to remove PID file during cleanup: ${String(rmError)}`); }); @@ -117,6 +128,11 @@ async function main(): Promise { } catch (destroyError) { log(`wallet.destroy() failed: ${String(destroyError)}`); } + try { + store.close(); + } catch (closeError) { + log(`store.close() failed: ${String(closeError)}`); + } await Promise.all([ rm(pidPath, { force: true }).catch((rmError: unknown) => { log(`Failed to remove PID file: ${String(rmError)}`); diff --git a/packages/wallet-cli/src/daemon/paths.test.ts b/packages/wallet-cli/src/daemon/paths.test.ts index 5ea1f81f092..ca127020309 100644 --- a/packages/wallet-cli/src/daemon/paths.test.ts +++ b/packages/wallet-cli/src/daemon/paths.test.ts @@ -11,6 +11,7 @@ describe('getDaemonPaths', () => { socketPath: join(dataDir, 'daemon.sock'), pidPath: join(dataDir, 'daemon.pid'), logPath: join(dataDir, 'daemon.log'), + dbPath: join(dataDir, 'wallet.db'), }); }); }); diff --git a/packages/wallet-cli/src/daemon/paths.ts b/packages/wallet-cli/src/daemon/paths.ts index 950fadc2d39..398b5b30dfb 100644 --- a/packages/wallet-cli/src/daemon/paths.ts +++ b/packages/wallet-cli/src/daemon/paths.ts @@ -6,12 +6,13 @@ import type { DaemonPaths } from './types'; * Resolve paths for daemon state files within the given data directory. * * @param dataDir - The base data directory (e.g. oclif config.dataDir). - * @returns Resolved paths for socket, PID file, and log file. + * @returns Resolved paths for socket, PID file, log file, and database file. */ export function getDaemonPaths(dataDir: string): DaemonPaths { return { socketPath: join(dataDir, 'daemon.sock'), pidPath: join(dataDir, 'daemon.pid'), logPath: join(dataDir, 'daemon.log'), + dbPath: join(dataDir, 'wallet.db'), }; } diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index 4d2793cb23c..8e9e8be50e3 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -19,6 +19,7 @@ export type DaemonPaths = { socketPath: string; pidPath: string; logPath: string; + dbPath: string; }; /** diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts index 16898475912..00b1e83f303 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -1,50 +1,312 @@ import { importSecretRecoveryPhrase, Wallet } from '@metamask/wallet'; +import { rmSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { KeyValueStore } from '../persistence/KeyValueStore'; +import * as persistenceModule from '../persistence/persistence'; import { createWallet } from './wallet-factory'; jest.mock('@metamask/wallet'); jest.mock('@metamask/remote-feature-flag-controller'); +jest.mock('node:fs/promises'); + +const mockRm = jest.mocked(rm); + +const createdTempDbPaths: string[] = []; + +/** + * Build a unique on-disk path under the OS temp dir so SQLite can create the + * file, while keeping the test isolated from concurrent runs. The path is + * tracked and cleaned up after each test (the production `rm` is mocked, so + * the factory never deletes it during the test itself). + * + * @param label - A short label that makes the resulting filename traceable. + * @returns An absolute file path inside `os.tmpdir()`. + */ +function tempDbPath(label: string): string { + const path = join( + tmpdir(), + `wallet-cli-${label}-${Date.now()}-${Math.random()}.db`, + ); + createdTempDbPaths.push(path); + return path; +} const MockWallet = jest.mocked(Wallet); const mockImportSrp = jest.mocked(importSecretRecoveryPhrase); const CONFIG = { + databasePath: ':memory:', infuraProjectId: 'test-key', password: 'test-pass', srp: 'test test test test test test test test test test test ball', }; describe('createWallet', () => { + let mockMessenger: { + call: jest.Mock; + subscribe: jest.Mock; + unsubscribe: jest.Mock; + }; + let mockControllerMetadata: Record; + + beforeEach(() => { + mockMessenger = { + call: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }; + mockControllerMetadata = {}; + MockWallet.mockImplementation( + () => + ({ + messenger: mockMessenger, + controllerMetadata: mockControllerMetadata, + state: {}, + destroy: jest.fn().mockResolvedValue(undefined), + }) as unknown as Wallet, + ); + mockRm.mockResolvedValue(undefined); + }); + + afterEach(() => { + while (createdTempDbPaths.length > 0) { + const path = createdTempDbPaths.pop() as string; + for (const candidate of [path, `${path}-wal`, `${path}-shm`]) { + rmSync(candidate, { force: true }); + } + } + }); + it('instantiates Wallet with the given infuraProjectId', async () => { await createWallet(CONFIG); expect(MockWallet).toHaveBeenCalledTimes(1); const args = MockWallet.mock.calls[0][0]; - expect(args.options.infuraProjectId).toBe('test-key'); + expect(args.infuraProjectId).toBe('test-key'); }); it('uses expected default options', async () => { await createWallet(CONFIG); const args = MockWallet.mock.calls[0][0]; - expect(args.options.clientVersion).toBe('0.0.0'); - expect(args.options.showApprovalRequest()).toBeUndefined(); - expect(args.options.getMetaMetricsId()).toBe('cli'); - expect(args.options.clientConfigApiService).toBeDefined(); + expect(args.clientVersion).toBe('0.0.0'); + expect(args.showApprovalRequest()).toBeUndefined(); + expect(args.getMetaMetricsId()).toBe('cli'); + expect(args.clientConfigApiService).toBeDefined(); }); - it('imports the secret recovery phrase with the given password', async () => { + it('imports the secret recovery phrase with the given password on first run', async () => { await createWallet(CONFIG); expect(mockImportSrp).toHaveBeenCalledWith( - expect.any(Wallet), + expect.objectContaining({ messenger: mockMessenger }), 'test-pass', 'test test test test test test test test test test test ball', ); }); - it('returns the wallet instance', async () => { - const wallet = await createWallet(CONFIG); - expect(wallet).toBeInstanceOf(Wallet); + it('returns the wallet and its backing KeyValueStore', async () => { + const { wallet, store } = await createWallet(CONFIG); + expect(wallet.messenger).toBe(mockMessenger); + expect(store).toBeInstanceOf(KeyValueStore); + store.close(); + }); + + it('hydrates the Wallet with state loaded from the store', async () => { + const tempStore = new KeyValueStore(':memory:'); + tempStore.set('AccountsController.internalAccounts', { + accounts: { 'a-id': { id: 'a-id' } }, + selectedAccount: 'a-id', + }); + const snapshot = tempStore.getAll(); + tempStore.close(); + + const loadStateSpy = jest + .spyOn(persistenceModule, 'loadState') + .mockReturnValue({ + AccountsController: { + internalAccounts: snapshot['AccountsController.internalAccounts'] as + | Record + | never, + }, + }); + + const { store } = await createWallet(CONFIG); + + const args = MockWallet.mock.calls[0][0]; + expect(args.state).toStrictEqual({ + AccountsController: { + internalAccounts: { + accounts: { 'a-id': { id: 'a-id' } }, + selectedAccount: 'a-id', + }, + }, + }); + + loadStateSpy.mockRestore(); + store.close(); + }); + + it('subscribes the store to controller state changes', async () => { + const subscribeSpy = jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(() => undefined); + + const { wallet, store } = await createWallet(CONFIG); + + expect(subscribeSpy).toHaveBeenCalledWith( + wallet.messenger, + wallet.controllerMetadata, + store, + ); + + subscribeSpy.mockRestore(); + store.close(); + }); + + it('skips importing the SRP when the store already contains a KeyringController vault', async () => { + jest.spyOn(persistenceModule, 'loadState').mockReturnValue({ + KeyringController: { vault: 'encrypted-vault-blob' }, + }); + + const { store } = await createWallet(CONFIG); + + expect(mockImportSrp).not.toHaveBeenCalled(); + + store.close(); + }); + + it('closes the store and rethrows when state hydration fails', async () => { + const failure = new Error('corrupt store'); + jest.spyOn(persistenceModule, 'loadState').mockImplementation(() => { + throw failure; + }); + + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + await expect(createWallet(CONFIG)).rejects.toThrow(failure); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('destroys the wallet and closes the store when SRP import rejects on first run', async () => { + const failure = new Error('bad SRP'); + mockImportSrp.mockRejectedValue(failure); + + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + await expect(createWallet(CONFIG)).rejects.toThrow(failure); + + const constructedWallet = MockWallet.mock.results[0]?.value as Wallet; + expect(constructedWallet.destroy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('removes the on-disk database files when first-run SRP import rejects', async () => { + mockImportSrp.mockRejectedValue(new Error('bad SRP')); + const databasePath = tempDbPath('rm-on-failure'); + + await expect(createWallet({ ...CONFIG, databasePath })).rejects.toThrow( + 'bad SRP', + ); + + expect(mockRm).toHaveBeenCalledWith(databasePath, { force: true }); + expect(mockRm).toHaveBeenCalledWith(`${databasePath}-wal`, { + force: true, + }); + expect(mockRm).toHaveBeenCalledWith(`${databasePath}-shm`, { + force: true, + }); + }); + + it('does not remove an in-memory database when first-run SRP import rejects', async () => { + mockImportSrp.mockRejectedValue(new Error('bad SRP')); + + await expect(createWallet(CONFIG)).rejects.toThrow('bad SRP'); + + expect(mockRm).not.toHaveBeenCalled(); + }); + + it('does not remove the database when SRP import succeeds on first run', async () => { + const databasePath = tempDbPath('success'); + const { store } = await createWallet({ ...CONFIG, databasePath }); + + expect(mockRm).not.toHaveBeenCalled(); + store.close(); + }); + + it('does not remove the database when failure occurs on a subsequent run', async () => { + jest.spyOn(persistenceModule, 'loadState').mockReturnValue({ + KeyringController: { vault: 'encrypted-vault-blob' }, + }); + jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockImplementation(() => { + throw new Error('subscribe failed'); + }); + + await expect( + createWallet({ ...CONFIG, databasePath: tempDbPath('subsequent-run') }), + ).rejects.toThrow('subscribe failed'); + + expect(mockRm).not.toHaveBeenCalled(); + }); + + it('tolerates rm rejection during first-run cleanup and still rethrows the original error', async () => { + const original = new Error('bad SRP'); + mockImportSrp.mockRejectedValue(original); + mockRm.mockRejectedValue(new Error('disk gone')); + + await expect( + createWallet({ ...CONFIG, databasePath: tempDbPath('rm-rejection') }), + ).rejects.toThrow(original); + }); + + it('tolerates wallet.destroy rejection during cleanup and still rethrows the original error', async () => { + const original = new Error('bad SRP'); + mockImportSrp.mockRejectedValue(original); + + const destroyError = new Error('destroy failed'); + const destroy = jest.fn().mockRejectedValue(destroyError); + MockWallet.mockImplementation( + () => + ({ + messenger: mockMessenger, + controllerMetadata: mockControllerMetadata, + state: {}, + destroy, + }) as unknown as Wallet, + ); + + await expect(createWallet(CONFIG)).rejects.toThrow(original); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it('destroys the wallet when subscribeToChanges throws', async () => { + const subscribeSpy = jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockImplementation(() => { + throw new Error('subscribe failed'); + }); + + await expect(createWallet(CONFIG)).rejects.toThrow('subscribe failed'); + + const constructedWallet = MockWallet.mock.results[0]?.value as Wallet; + expect(constructedWallet.destroy).toHaveBeenCalledTimes(1); + + subscribeSpy.mockRestore(); + }); + + it('closes the store without destroying when Wallet construction throws', async () => { + const ctorError = new Error('wallet ctor failed'); + MockWallet.mockImplementation(() => { + throw ctorError; + }); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + await expect(createWallet(CONFIG)).rejects.toThrow(ctorError); + expect(closeSpy).toHaveBeenCalled(); }); }); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 3152a227ef3..8a8dfb7d7d7 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -5,42 +5,122 @@ import { EnvironmentType, } from '@metamask/remote-feature-flag-controller'; import { importSecretRecoveryPhrase, Wallet } from '@metamask/wallet'; +import { rm } from 'node:fs/promises'; + +import { KeyValueStore } from '../persistence/KeyValueStore'; +import { loadState, subscribeToChanges } from '../persistence/persistence'; + +const IN_MEMORY_DATABASE_PATH = ':memory:'; + +export type CreateWalletResult = { + wallet: Wallet; + store: KeyValueStore; +}; /** - * Create a configured Wallet instance for daemon use. + * Create a configured Wallet instance for daemon use, backed by a SQLite + * key-value store for persistence. + * + * Loads any previously-persisted controller state from the store and uses it + * to seed the wallet, then subscribes the store to subsequent state changes + * so all persist-flagged properties are written through. + * + * If the store does not yet contain a keyring vault (first-run), the supplied + * secret recovery phrase is imported. On subsequent runs, the persisted vault + * is reused and the SRP is unused; the wallet still starts locked and the + * caller is responsible for unlocking it (`KeyringController:submitPassword`) + * before any keyring-bound operation. + * + * On any failure after the wallet is constructed, the wallet is destroyed + * before the store is closed so persistence handlers unsubscribe cleanly. On a + * first-run failure, the database file is also removed so a retry does not + * latch onto an orphaned partial vault. * * @param config - Wallet configuration. + * @param config.databasePath - The path to the SQLite database file (or + * `':memory:'` for ephemeral use). * @param config.infuraProjectId - The Infura project ID for network access. * @param config.password - The wallet password. * @param config.srp - The secret recovery phrase (BIP-39 mnemonic). - * @returns A new Wallet instance with the SRP imported. + * @returns The Wallet instance and the underlying KeyValueStore. The caller + * owns the store and must close it after destroying the wallet (closing + * first would cause in-flight persistence writes during teardown to fail). */ export async function createWallet({ + databasePath, infuraProjectId, password, srp, }: { + databasePath: string; infuraProjectId: string; password: string; srp: string; -}): Promise { - const wallet = new Wallet({ - infuraProjectId, - clientVersion: '0.0.0', - // TODO: Implement showApprovalRequest - showApprovalRequest: (): undefined => undefined, - clientConfigApiService: new ClientConfigApiService({ - fetch: globalThis.fetch, - config: { - client: ClientType.Extension, - distribution: DistributionType.Main, - environment: EnvironmentType.Production, - }, - }), - getMetaMetricsId: (): string => 'cli', - }); - - await importSecretRecoveryPhrase(wallet, password, srp); - - return wallet; +}): Promise { + const store = new KeyValueStore(databasePath); + let wallet: Wallet | undefined; + let wasFirstRun = false; + + try { + const state = loadState(store); + wasFirstRun = !hasPersistedKeyring(state); + + wallet = new Wallet({ + state, + infuraProjectId, + clientVersion: '0.0.0', + // TODO: Implement showApprovalRequest + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'cli', + }); + + subscribeToChanges(wallet.messenger, wallet.controllerMetadata, store); + + if (wasFirstRun) { + await importSecretRecoveryPhrase(wallet, password, srp); + } + + return { wallet, store }; + } catch (error) { + if (wallet) { + await wallet.destroy().catch(() => undefined); + } + store.close(); + + if (wasFirstRun && databasePath !== IN_MEMORY_DATABASE_PATH) { + // Best-effort cleanup of the on-disk SQLite files (main, WAL, SHM) so + // a partially-persisted KeyringController vault cannot mislead the next + // run into skipping SRP import. + await Promise.all( + [databasePath, `${databasePath}-wal`, `${databasePath}-shm`].map( + (path) => rm(path, { force: true }).catch(() => undefined), + ), + ); + } + + throw error; + } +} + +/** + * Determine whether the loaded state already contains a keyring vault. + * + * The KeyringController persists its `vault` once an SRP has been imported, + * so its presence indicates that first-run setup completed before. + * + * @param state - The state loaded from the key-value store. + * @returns True if a KeyringController vault string is present. + */ +function hasPersistedKeyring( + state: Record>, +): boolean { + return typeof state.KeyringController?.vault === 'string'; } diff --git a/packages/wallet/src/persistence/KeyValueStore.test.ts b/packages/wallet-cli/src/persistence/KeyValueStore.test.ts similarity index 100% rename from packages/wallet/src/persistence/KeyValueStore.test.ts rename to packages/wallet-cli/src/persistence/KeyValueStore.test.ts diff --git a/packages/wallet/src/persistence/KeyValueStore.ts b/packages/wallet-cli/src/persistence/KeyValueStore.ts similarity index 100% rename from packages/wallet/src/persistence/KeyValueStore.ts rename to packages/wallet-cli/src/persistence/KeyValueStore.ts diff --git a/packages/wallet/src/persistence/persistence.test.ts b/packages/wallet-cli/src/persistence/persistence.test.ts similarity index 99% rename from packages/wallet/src/persistence/persistence.test.ts rename to packages/wallet-cli/src/persistence/persistence.test.ts index ceee3471531..b4f8c660a83 100644 --- a/packages/wallet/src/persistence/persistence.test.ts +++ b/packages/wallet-cli/src/persistence/persistence.test.ts @@ -1,11 +1,11 @@ import type { StateMetadataConstraint } from '@metamask/base-controller'; import type { Json } from '@metamask/utils'; - import type { DefaultActions, DefaultEvents, RootMessenger, -} from '../initialization'; +} from '@metamask/wallet'; + import { KeyValueStore } from './KeyValueStore'; import { loadState, subscribeToChanges } from './persistence'; diff --git a/packages/wallet/src/persistence/persistence.ts b/packages/wallet-cli/src/persistence/persistence.ts similarity index 98% rename from packages/wallet/src/persistence/persistence.ts rename to packages/wallet-cli/src/persistence/persistence.ts index 30ee2682c89..1297d14face 100644 --- a/packages/wallet/src/persistence/persistence.ts +++ b/packages/wallet-cli/src/persistence/persistence.ts @@ -1,13 +1,13 @@ import type { StateMetadataConstraint } from '@metamask/base-controller'; import { hasProperty } from '@metamask/utils'; import type { Json } from '@metamask/utils'; -import type { Patch } from 'immer'; - import type { DefaultActions, DefaultEvents, RootMessenger, -} from '../initialization'; +} from '@metamask/wallet'; +import type { Patch } from 'immer'; + import type { KeyValueStore } from './KeyValueStore'; /** @@ -80,7 +80,9 @@ export function loadState( */ export function subscribeToChanges( messenger: RootMessenger, - controllerMetadata: Record, + controllerMetadata: Readonly< + Record> + >, store: KeyValueStore, ): () => void { const unsubscribers: (() => void)[] = []; diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index b518709c7b8..84da8715662 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,4 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed + +- **BREAKING:** Drop the `better-sqlite3`-backed persistence layer and `./persistence` subpath export ([#8682](https://github.com/MetaMask/core/issues/8682)) + - The `KeyValueStore`, `loadState`, and `subscribeToChanges` exports previously available via `@metamask/wallet/persistence` have been removed. + - The `better-sqlite3` and `@types/better-sqlite3` dependencies are no longer pulled in by this package. + - Consumers should keep their own persistence layer and inject controller state through the existing `state` option on `Wallet`. A Node-only SQLite implementation now lives in `@metamask/wallet-cli`. + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet/README.md b/packages/wallet/README.md index bc3cf7e5c1b..da275a947df 100644 --- a/packages/wallet/README.md +++ b/packages/wallet/README.md @@ -10,16 +10,6 @@ or `npm install @metamask/wallet` -## Troubleshooting - -### Rebuilding `better-sqlite3` - -This package depends on `better-sqlite3`, which includes a native C addon. The prebuilt binary is downloaded automatically during `yarn install`. If you switch Node versions or branches and the binding is missing, rebuild it with: - -```sh -cd node_modules/better-sqlite3 && npx prebuild-install -``` - ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 97bce884081..de01df9694a 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -32,16 +32,6 @@ "default": "./dist/index.cjs" } }, - "./persistence": { - "import": { - "types": "./dist/persistence/index.d.mts", - "default": "./dist/persistence/index.mjs" - }, - "require": { - "types": "./dist/persistence/index.d.cts", - "default": "./dist/persistence/index.cjs" - } - }, "./package.json": "./package.json" }, "publishConfig": { @@ -75,14 +65,12 @@ "@metamask/remote-feature-flag-controller": "^4.2.0", "@metamask/scure-bip39": "^2.1.1", "@metamask/transaction-controller": "^65.0.0", - "@metamask/utils": "^11.9.0", - "better-sqlite3": "^12.9.0" + "@metamask/utils": "^11.9.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@metamask/foundryup": "^1.0.1", "@ts-bridge/cli": "^0.6.4", - "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", diff --git a/packages/wallet/scripts/install-binaries.sh b/packages/wallet/scripts/install-binaries.sh index cf087190a59..e19ed5ff335 100755 --- a/packages/wallet/scripts/install-binaries.sh +++ b/packages/wallet/scripts/install-binaries.sh @@ -4,9 +4,8 @@ set -e set -o pipefail # Pin cwd to the package root so all paths are predictable regardless of how -# this script is invoked. Also derive the monorepo root (two levels up). +# this script is invoked. PACKAGE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -MONOREPO_ROOT="$(cd "${PACKAGE_ROOT}/../.." && pwd)" cd "${PACKAGE_ROOT}" # Run foundryup's TypeScript entry point directly via tsx. This avoids having @@ -16,16 +15,3 @@ if ! output=$(yarn tsx ../foundryup/src/cli.ts --binaries anvil 2>&1); then echo "$output" >&2 exit 1 fi - -# Install the better-sqlite3 native addon if missing. Yarn has -# `enableScripts: false` globally, so install scripts never run during -# `yarn install` and the addon may be absent from the filesystem. Invoke the -# prebuild-install binary directly to fetch a matching prebuild for the active -# Node version and platform. -BETTER_SQLITE3_DIR="${MONOREPO_ROOT}/node_modules/better-sqlite3" -if [ ! -f "${BETTER_SQLITE3_DIR}/build/Release/better_sqlite3.node" ]; then - ( - cd "${BETTER_SQLITE3_DIR}" - "${MONOREPO_ROOT}/node_modules/.bin/prebuild-install" - ) -fi diff --git a/packages/wallet/src/persistence/index.ts b/packages/wallet/src/persistence/index.ts deleted file mode 100644 index 08081afc4b3..00000000000 --- a/packages/wallet/src/persistence/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { KeyValueStore } from './KeyValueStore'; -export { loadState, subscribeToChanges } from './persistence'; diff --git a/yarn.config.cjs b/yarn.config.cjs index 34445ab5aec..bbaffece7ef 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -160,7 +160,12 @@ module.exports = defineConfig({ // All non-root packages must have the same "test" script. // @metamask/wallet prepends an anvil binary download to the test script. - if (workspace.ident !== '@metamask/wallet') { + // @metamask/wallet-cli prepends a better-sqlite3 prebuild fetch to the + // test script. + if ( + workspace.ident !== '@metamask/wallet' && + workspace.ident !== '@metamask/wallet-cli' + ) { expectWorkspaceField( workspace, 'scripts.test', From 05fe5af5019993c24d0db096e284ac8bef5a57d9 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 12 May 2026 16:05:14 +0200 Subject: [PATCH 26/32] update lockfile --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 21de7e84b94..be0f14d051c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5824,7 +5824,9 @@ __metadata: "@metamask/wallet": "npm:^0.0.0" "@oclif/core": "npm:^4.10.5" "@ts-bridge/cli": "npm:^0.6.4" + "@types/better-sqlite3": "npm:^7.6.13" "@types/jest": "npm:^29.5.14" + better-sqlite3: "npm:^12.9.0" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" ts-jest: "npm:^29.2.5" @@ -5856,9 +5858,7 @@ __metadata: "@metamask/transaction-controller": "npm:^65.0.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/better-sqlite3": "npm:^7.6.13" "@types/jest": "npm:^29.5.14" - better-sqlite3: "npm:^12.9.0" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" nock: "npm:^13.3.1" From 959252d0686238a84d90ec7ab8ed5d36da4714d2 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 12 May 2026 16:45:08 +0200 Subject: [PATCH 27/32] fix(wallet-cli): Race-proof daemon startup and surface unreachable state Resolves operational footguns surfaced by review of the daemon code: - `pingDaemon` returns a discriminated `{ status: 'responsive' | 'absent' | 'unreachable' }` rather than a boolean. Callers can now distinguish "no daemon" from "wedged daemon"; previously both produced silent duplicate-spawns or wrong cleanup decisions. - `ensureDaemon` returns `{ state: 'already-running' | 'started' }` and refuses to spawn when the socket exists but is unreachable. The `daemon start` command surfaces the "already running" case rather than silently pretending new flags took effect. - `daemon-entry` claims the daemon slot atomically: pre-flight refuses to start when a responsive daemon owns the socket, then writes the PID file with `flag: 'wx'` (exclusive create) recording `${pid}\n${startTime}`. Cleanup paths only remove the PID file when its contents still match. - `stop-daemon` only signals the recorded PID when we have evidence the socket existed (responsive or unreachable); for absent sockets we treat the PID file as stale and skip signalling, removing the PID-reuse footgun. Post-stop cleanup wraps `rm` in best-effort handlers. - `ensureDaemon` watches `child.on('exit')` so a daemon that crashes during startup surfaces a real error pointing at the daemon log instead of a 30-second "did not start" timeout. - `daemon call` catches socket errors and prints a friendly "daemon is not running" hint for `ENOENT`/`ECONNREFUSED`. - `daemon purge` deletes a whitelist of daemon-owned files (`pidPath`, `socketPath`, `logPath`, `dbPath` + `-wal`/`-shm`) rather than rm'ing the entire oclif `dataDir`. - `daemon status` reports the unreachable case distinctly and warns when the local PID file disagrees with the running daemon's reported PID. - `isProcessAlive` rethrows unknown errors rather than treating them as "process is gone". - `sendCommand` verifies the response id matches the request id. - Reworded a misleading inline comment and a `// TODO: Delete unsafe flags` that no longer applied. Fixed a README path inaccuracy. 181 wallet-cli tests pass at 100% line/branch/function coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/wallet-cli/README.md | 2 +- .../wallet-cli/src/commands/daemon/call.ts | 25 ++- .../wallet-cli/src/commands/daemon/purge.ts | 24 ++- .../wallet-cli/src/commands/daemon/start.ts | 13 +- .../wallet-cli/src/commands/daemon/status.ts | 18 +- .../src/daemon/daemon-client.test.ts | 109 +++++++--- .../wallet-cli/src/daemon/daemon-client.ts | 57 +++-- .../src/daemon/daemon-entry.test.ts | 199 ++++++++++++++++-- .../wallet-cli/src/daemon/daemon-entry.ts | 133 +++++++++++- .../src/daemon/daemon-spawn.test.ts | 160 +++++++++++--- .../wallet-cli/src/daemon/daemon-spawn.ts | 59 +++++- .../wallet-cli/src/daemon/stop-daemon.test.ts | 132 ++++++++++-- packages/wallet-cli/src/daemon/stop-daemon.ts | 58 +++-- packages/wallet-cli/src/daemon/utils.test.ts | 16 +- packages/wallet-cli/src/daemon/utils.ts | 11 +- 15 files changed, 855 insertions(+), 161 deletions(-) diff --git a/packages/wallet-cli/README.md b/packages/wallet-cli/README.md index 3c1bf905272..8ea09256bf5 100644 --- a/packages/wallet-cli/README.md +++ b/packages/wallet-cli/README.md @@ -22,7 +22,7 @@ If you switch Node versions or branches and the binding is missing, re-run: yarn workspace @metamask/wallet-cli run test:prepare ``` -Or invoke `prebuild-install` directly from the workspace root: +Or invoke `prebuild-install` directly from the monorepo root (where `better-sqlite3` is hoisted): ```sh cd node_modules/better-sqlite3 && node ../.bin/prebuild-install diff --git a/packages/wallet-cli/src/commands/daemon/call.ts b/packages/wallet-cli/src/commands/daemon/call.ts index 8feac20ff7a..2e443efad40 100644 --- a/packages/wallet-cli/src/commands/daemon/call.ts +++ b/packages/wallet-cli/src/commands/daemon/call.ts @@ -3,6 +3,7 @@ import { Args, Command, Flags } from '@oclif/core'; import { sendCommand } from '../../daemon/daemon-client'; import { getDaemonPaths } from '../../daemon/paths'; +import { isErrorWithCode } from '../../daemon/utils'; export default class DaemonCall extends Command { static override description = 'Call a messenger action on the wallet daemon'; @@ -38,7 +39,6 @@ export default class DaemonCall extends Command { const { action } = args; const timeoutMs = flags.timeout; - // Build the params array for the `call` RPC method: [action, ...args] let rpcParams: unknown[] = [action]; if (args.params !== undefined) { let parsed: unknown; @@ -57,12 +57,23 @@ export default class DaemonCall extends Command { const { socketPath } = getDaemonPaths(this.config.dataDir); - const response = await sendCommand({ - socketPath, - method: 'call', - params: rpcParams, - ...(timeoutMs === undefined ? {} : { timeoutMs }), - }); + let response; + try { + response = await sendCommand({ + socketPath, + method: 'call', + params: rpcParams, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + } catch (error) { + if ( + isErrorWithCode(error, 'ENOENT') || + isErrorWithCode(error, 'ECONNREFUSED') + ) { + this.error('Daemon is not running. Start it with `mm daemon start`.'); + } + this.error(error instanceof Error ? error.message : String(error)); + } if (isJsonRpcFailure(response)) { this.error( diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts index 2a09db3ee9c..b333e5cf26d 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -35,17 +35,33 @@ export default class DaemonPurge extends Command { } } - const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); + const paths = getDaemonPaths(this.config.dataDir); - const stopped = await stopDaemon(socketPath, pidPath, (message) => - this.log(message), + const stopped = await stopDaemon( + paths.socketPath, + paths.pidPath, + (message) => this.log(message), ); if (!stopped) { this.error('Refusing to delete state while the daemon is still running.'); } - await rm(this.config.dataDir, { recursive: true, force: true }); + // Whitelist only the daemon-owned files rather than rm'ing the entire + // oclif dataDir, which may hold unrelated state (caches, oclif lock + // files, future config). `force: true` makes ENOENT a no-op for any + // file already removed by stopDaemon. + await Promise.all( + [paths.pidPath, paths.socketPath, paths.logPath, paths.dbPath].map( + async (path) => rm(path, { force: true }), + ), + ); + // Remove the SQLite sidecar files too (WAL/SHM are created in WAL mode). + await Promise.all( + [`${paths.dbPath}-wal`, `${paths.dbPath}-shm`].map(async (path) => + rm(path, { force: true }), + ), + ); this.log('All daemon state deleted.'); } diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts index e0223a99ce9..fb14e29f4d6 100644 --- a/packages/wallet-cli/src/commands/daemon/start.ts +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -1,7 +1,6 @@ import { Command, Flags } from '@oclif/core'; import { ensureDaemon } from '../../daemon/daemon-spawn'; -import { getDaemonPaths } from '../../daemon/paths'; export default class DaemonStart extends Command { static override description = 'Start the wallet daemon'; @@ -11,7 +10,6 @@ export default class DaemonStart extends Command { 'INFURA_PROJECT_ID= MM_WALLET_PASSWORD= MM_WALLET_SRP= <%= config.bin %> daemon start', ]; - // TODO: Delete unsafe flags static override flags = { 'infura-project-id': Flags.string({ description: 'Infura project ID for network access', @@ -37,7 +35,7 @@ export default class DaemonStart extends Command { const infuraProjectId = flags['infura-project-id']; const { password, srp } = flags; - await ensureDaemon({ + const { state, socketPath } = await ensureDaemon({ dataDir: this.config.dataDir, infuraProjectId, password, @@ -45,7 +43,14 @@ export default class DaemonStart extends Command { packageRoot: this.config.root, }); - const { socketPath } = getDaemonPaths(this.config.dataDir); + if (state === 'already-running') { + this.log( + `Daemon already running. Socket: ${socketPath}. ` + + `The provided flags were not applied; run \`mm daemon stop\` and start again to change them.`, + ); + return; + } + this.log(`Daemon running. Socket: ${socketPath}`); } } diff --git a/packages/wallet-cli/src/commands/daemon/status.ts b/packages/wallet-cli/src/commands/daemon/status.ts index f8a2b618209..4bd3a23f4ef 100644 --- a/packages/wallet-cli/src/commands/daemon/status.ts +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -4,7 +4,7 @@ import { Command } from '@oclif/core'; import { pingDaemon, sendCommand } from '../../daemon/daemon-client'; import { getDaemonPaths } from '../../daemon/paths'; import type { DaemonStatusInfo } from '../../daemon/types'; -import { isProcessAlive, readPidFile } from '../../daemon/utils'; +import { readPidFile } from '../../daemon/utils'; export default class DaemonStatus extends Command { static override description = 'Check the status of the wallet daemon'; @@ -15,17 +15,17 @@ export default class DaemonStatus extends Command { const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); const pid = await readPidFile(pidPath); - const processAlive = pid !== undefined && isProcessAlive(pid); - const socketResponsive = await pingDaemon(socketPath); + const ping = await pingDaemon(socketPath); - if (!processAlive && !socketResponsive) { + if (ping.status === 'absent') { this.log('Daemon is not running.'); return; } - if (processAlive && !socketResponsive) { + if (ping.status === 'unreachable') { + const pidPart = pid === undefined ? '' : ` (recorded PID: ${pid})`; this.log( - `Daemon process exists (PID: ${pid}) but socket is not responding.`, + `Daemon socket exists at ${socketPath} but is unresponsive${pidPart}: ${ping.error.message}`, ); return; } @@ -52,6 +52,12 @@ export default class DaemonStatus extends Command { } const status = response.result as DaemonStatusInfo; + if (pid !== undefined && pid !== status.pid) { + this.log( + `Warning: PID file records ${pid} but the running daemon reports ${status.pid}. ` + + `Local state may be stale; consider \`mm daemon purge\`.`, + ); + } this.log( `Daemon is running. PID: ${status.pid}, Uptime: ${status.uptime}s`, ); diff --git a/packages/wallet-cli/src/daemon/daemon-client.test.ts b/packages/wallet-cli/src/daemon/daemon-client.test.ts index a3cf69f717b..89133e8dab0 100644 --- a/packages/wallet-cli/src/daemon/daemon-client.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-client.test.ts @@ -39,17 +39,37 @@ function setupMockSocket(): Socket { return socket; } -const VALID_RESPONSE: JsonRpcResponse = { - jsonrpc: '2.0', - id: 'test-id', - result: { status: 'ok' }, -}; +/** + * Build a JSON-RPC response that mirrors back the request id from the most + * recent `mockWriteLine` call. `sendCommand` now verifies id correlation, so + * static fixtures no longer work — the response must echo the generated id. + * + * @param overrides - Optional fields to override on the response. + * @returns A function suitable for `mockReadLine.mockImplementation`. + */ +function respondWithMatchingId( + overrides: Partial = {}, +): () => Promise { + return async () => { + const lastWrite = mockWriteLine.mock.calls.at(-1)?.[1]; + const sentId = + typeof lastWrite === 'string' + ? (JSON.parse(lastWrite).id as string) + : 'test-id'; + return JSON.stringify({ + jsonrpc: '2.0', + id: sentId, + result: { status: 'ok' }, + ...overrides, + }); + }; +} describe('sendCommand', () => { it('sends a JSON-RPC request and returns the response', async () => { const socket = setupMockSocket(); mockWriteLine.mockResolvedValue(undefined); - mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + mockReadLine.mockImplementation(respondWithMatchingId()); const response = await sendCommand({ socketPath: '/tmp/test.sock', @@ -71,7 +91,7 @@ describe('sendCommand', () => { it('includes params when provided', async () => { setupMockSocket(); mockWriteLine.mockResolvedValue(undefined); - mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + mockReadLine.mockImplementation(respondWithMatchingId()); await sendCommand({ socketPath: '/tmp/test.sock', @@ -86,7 +106,7 @@ describe('sendCommand', () => { it('omits params when undefined', async () => { setupMockSocket(); mockWriteLine.mockResolvedValue(undefined); - mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + mockReadLine.mockImplementation(respondWithMatchingId()); await sendCommand({ socketPath: '/tmp/test.sock', @@ -100,7 +120,7 @@ describe('sendCommand', () => { it('passes timeoutMs to readLine', async () => { setupMockSocket(); mockWriteLine.mockResolvedValue(undefined); - mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + mockReadLine.mockImplementation(respondWithMatchingId()); await sendCommand({ socketPath: '/tmp/test.sock', @@ -111,6 +131,22 @@ describe('sendCommand', () => { expect(mockReadLine).toHaveBeenCalledWith(expect.anything(), 5000); }); + it('throws when the response id does not match the request id', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue( + JSON.stringify({ + jsonrpc: '2.0', + id: 'unrelated-id', + result: { status: 'ok' }, + }), + ); + + await expect( + sendCommand({ socketPath: '/tmp/test.sock', method: 'test' }), + ).rejects.toThrow(/does not match request id/u); + }); + it('retries once on ECONNREFUSED', async () => { const socket = setupMockSocket(); mockWriteLine.mockResolvedValue(undefined); @@ -118,7 +154,7 @@ describe('sendCommand', () => { .mockRejectedValueOnce( Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), ) - .mockResolvedValueOnce(JSON.stringify(VALID_RESPONSE)); + .mockImplementationOnce(respondWithMatchingId()); const response = await sendCommand({ socketPath: '/tmp/test.sock', @@ -136,7 +172,7 @@ describe('sendCommand', () => { .mockRejectedValueOnce( Object.assign(new Error('reset'), { code: 'ECONNRESET' }), ) - .mockResolvedValueOnce(JSON.stringify(VALID_RESPONSE)); + .mockImplementationOnce(respondWithMatchingId()); const response = await sendCommand({ socketPath: '/tmp/test.sock', @@ -171,15 +207,13 @@ describe('sendCommand', () => { }); describe('pingDaemon', () => { - it('returns true when daemon responds', async () => { - setupMockSocket(); - mockWriteLine.mockResolvedValue(undefined); - mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); - - expect(await pingDaemon('/tmp/test.sock')).toBe(true); - }); - - it('returns false when daemon is unresponsive', async () => { + /** + * Configure `createConnection` to emit a connection error synchronously. + * + * @param code - The Node errno code (e.g. ENOENT, ECONNREFUSED) the mock + * socket should emit on the next attempt. + */ + function mockConnectionError(code: string): void { mockCreateConnection.mockImplementation((_path: unknown) => { const emitter = new EventEmitter(); const socket = Object.assign(emitter, { @@ -188,14 +222,39 @@ describe('pingDaemon', () => { removeListener: emitter.removeListener.bind(emitter), }) as unknown as Socket; process.nextTick(() => - socket.emit( - 'error', - Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), - ), + socket.emit('error', Object.assign(new Error(code), { code })), ); return socket; }); + } + + it('returns responsive when daemon responds', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockImplementation(respondWithMatchingId()); - expect(await pingDaemon('/tmp/test.sock')).toBe(false); + expect(await pingDaemon('/tmp/test.sock')).toStrictEqual({ + status: 'responsive', + }); + }); + + it('returns absent when the socket file does not exist', async () => { + mockConnectionError('ENOENT'); + + expect(await pingDaemon('/tmp/test.sock')).toStrictEqual({ + status: 'absent', + }); + }); + + it('returns unreachable when the socket exists but is wedged', async () => { + // ECONNREFUSED is retried once; both attempts will reject with the same + // mock implementation. + mockConnectionError('ECONNREFUSED'); + + const result = await pingDaemon('/tmp/test.sock'); + expect(result).toStrictEqual({ + status: 'unreachable', + error: expect.any(Error), + }); }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-client.ts b/packages/wallet-cli/src/daemon/daemon-client.ts index dd80f06753e..42c2ecaac99 100644 --- a/packages/wallet-cli/src/daemon/daemon-client.ts +++ b/packages/wallet-cli/src/daemon/daemon-client.ts @@ -1,10 +1,11 @@ -import type { JsonRpcResponse } from '@metamask/utils'; +import type { JsonRpcParams, JsonRpcResponse } from '@metamask/utils'; import { assertIsJsonRpcResponse } from '@metamask/utils'; import { randomUUID } from 'node:crypto'; import { createConnection } from 'node:net'; import type { Socket } from 'node:net'; import { readLine, writeLine } from './socket-line'; +import { isErrorWithCode } from './utils'; const DEFAULT_TIMEOUT_MS = 30_000; @@ -17,17 +18,11 @@ type SendCommandOptions = { /** The RPC method name. */ method: string; /** Optional method parameters (object or positional array). */ - params?: Record | unknown[] | undefined; + params?: JsonRpcParams | undefined; /** Response read timeout in milliseconds (default: 30 000). */ timeoutMs?: number | undefined; }; -/** - * Connect to a Unix domain socket. - * - * @param socketPath - The socket path to connect to. - * @returns A connected socket. - */ async function connectSocket(socketPath: string): Promise { return new Promise((resolve, reject) => { const socket = createConnection(socketPath, () => { @@ -44,7 +39,8 @@ async function connectSocket(socketPath: string): Promise { * * Opens a connection, writes one JSON-RPC request line, reads one JSON-RPC * response line, then closes the connection. Retries once after a short delay - * on transient connection errors (ECONNREFUSED, ECONNRESET). + * on transient connection errors (ECONNREFUSED, ECONNRESET). Verifies that the + * response `id` matches the outgoing request `id`. * * @param options - Command options. * @param options.socketPath - The Unix socket path. @@ -76,6 +72,11 @@ export async function sendCommand({ const responseLine = await readLine(socket, effectiveTimeout); const parsed: unknown = JSON.parse(responseLine); assertIsJsonRpcResponse(parsed); + if (parsed.id !== id) { + throw new Error( + `JSON-RPC response id ${JSON.stringify(parsed.id)} does not match request id ${JSON.stringify(id)}`, + ); + } return parsed; } finally { socket.destroy(); @@ -85,8 +86,10 @@ export async function sendCommand({ try { return await attempt(); } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - if (code !== 'ECONNREFUSED' && code !== 'ECONNRESET') { + if ( + !isErrorWithCode(error, 'ECONNREFUSED') && + !isErrorWithCode(error, 'ECONNRESET') + ) { throw error; } await new Promise((resolve) => setTimeout(resolve, 100)); @@ -94,18 +97,38 @@ export async function sendCommand({ } } +/** + * Outcome of a daemon health check. + * + * - `'responsive'`: the daemon answered a `getStatus` RPC. + * - `'absent'`: the socket file does not exist (ENOENT). No daemon present. + * - `'unreachable'`: the socket exists but cannot be queried (refused after + * retry, timeout, permission denied, parse error, etc.). Callers should + * refuse to take destructive action against an unreachable daemon — the + * process may still be alive. + */ +export type PingResult = + | { status: 'responsive' } + | { status: 'absent' } + | { status: 'unreachable'; error: Error }; + /** * Check whether the daemon is running by sending a lightweight `getStatus` - * RPC call. + * RPC call. Distinguishes "no daemon present" (socket file missing) from + * "daemon present but unreachable" (socket file exists but the daemon is + * wedged, mid-shutdown, or owned by a different user). * * @param socketPath - The Unix socket path. - * @returns True if the daemon responds to the RPC call. + * @returns A {@link PingResult} describing the daemon's reachability. */ -export async function pingDaemon(socketPath: string): Promise { +export async function pingDaemon(socketPath: string): Promise { try { await sendCommand({ socketPath, method: 'getStatus', timeoutMs: 3_000 }); - return true; - } catch { - return false; + return { status: 'responsive' }; + } catch (error: unknown) { + if (isErrorWithCode(error, 'ENOENT')) { + return { status: 'absent' }; + } + return { status: 'unreachable', error: error as Error }; } } diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 97d6466fee8..464bd2f575e 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -1,6 +1,7 @@ import { mkdirSync } from 'node:fs'; -import { appendFile, rm, writeFile } from 'node:fs/promises'; +import { appendFile, readFile, rm, writeFile } from 'node:fs/promises'; +import { pingDaemon } from './daemon-client'; import { getDaemonPaths } from './paths'; import { startRpcSocketServer } from './rpc-socket-server'; import type { RpcSocketServerHandle } from './rpc-socket-server'; @@ -8,22 +9,41 @@ import { createWallet } from './wallet-factory'; jest.mock('node:fs'); jest.mock('node:fs/promises'); +jest.mock('./daemon-client'); jest.mock('./paths'); jest.mock('./rpc-socket-server'); jest.mock('./wallet-factory'); const mockMkdirSync = jest.mocked(mkdirSync); const mockAppendFile = jest.mocked(appendFile); +const mockReadFile = jest.mocked(readFile); const mockWriteFile = jest.mocked(writeFile); const mockRm = jest.mocked(rm); +const mockPingDaemon = jest.mocked(pingDaemon); const mockGetDaemonPaths = jest.mocked(getDaemonPaths); const mockStartRpcSocketServer = jest.mocked(startRpcSocketServer); const mockCreateWallet = jest.mocked(createWallet); const ORIGINAL_ENV = process.env; +const ABSENT = { status: 'absent' as const }; +const RESPONSIVE = { status: 'responsive' as const }; +const UNREACHABLE = { + status: 'unreachable' as const, + error: new Error('wedged'), +}; + type MockCreateWalletResult = Awaited>; +/** + * Build an ENOENT NodeJS.ErrnoException for fs/promises mock rejections. + * + * @returns An error mimicking what `readFile` throws when a file is missing. + */ +function enoent(): NodeJS.ErrnoException { + return Object.assign(new Error('not found'), { code: 'ENOENT' }); +} + /** * Create a mock createWallet result with a mocked wallet and store. * @@ -72,9 +92,13 @@ describe('daemon-entry', () => { logPath: '/tmp/daemon.log', dbPath: '/tmp/wallet.db', }); + // Default: no prior daemon state (pre-flight readFile + ownership readFile + // both miss). Tests that need a stale PID file override these per-call. + mockReadFile.mockRejectedValue(enoent()); mockWriteFile.mockResolvedValue(undefined); mockRm.mockResolvedValue(undefined); mockAppendFile.mockResolvedValue(undefined); + mockPingDaemon.mockResolvedValue(ABSENT); }); afterEach(() => { @@ -88,12 +112,8 @@ describe('daemon-entry', () => { * Returns after main() settles. */ async function importDaemonEntry(): Promise { - // The module under test calls main() at top level on import. - // We use jest.isolateModules to re-import it fresh in each test - // after setting up mocks and env vars. await jest.isolateModulesAsync(async () => { await import('./daemon-entry'); - // Flush microtasks so main()'s .catch() handler settles for (let i = 0; i < 10; i++) { await new Promise((resolve) => process.nextTick(resolve)); } @@ -144,7 +164,7 @@ describe('daemon-entry', () => { expect(process.exitCode).toBe(1); }); - it('creates data dir, wallet, server, and writes PID on successful startup', async () => { + it('creates data dir, wallet, server, and writes PID exclusively on successful startup', async () => { mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); @@ -161,7 +181,8 @@ describe('daemon-entry', () => { }); expect(mockWriteFile).toHaveBeenCalledWith( '/tmp/daemon.pid', - String(process.pid), + expect.stringMatching(new RegExp(`^${process.pid}\\n\\d+\\n$`, 'u')), + { flag: 'wx' }, ); expect(mockStartRpcSocketServer).toHaveBeenCalledWith( expect.objectContaining({ @@ -186,10 +207,104 @@ describe('daemon-entry', () => { ); }); + it('refuses to start when a responsive daemon already owns the socket', async () => { + mockReadFile.mockResolvedValue('9999\n12345\n'); + mockPingDaemon.mockResolvedValue(RESPONSIVE); + mockCreateWallet.mockResolvedValue(createMockWallet()); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('A daemon is already running'), + ); + expect(process.exitCode).toBe(1); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('refuses to start when a responsive daemon owns the socket without a PID file', async () => { + // No PID file (ENOENT default) but pingDaemon returns responsive. + mockPingDaemon.mockResolvedValue(RESPONSIVE); + mockCreateWallet.mockResolvedValue(createMockWallet()); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('A daemon is already running'), + ); + expect(process.exitCode).toBe(1); + }); + + it('removes a stale unreachable socket file when no PID file is present', async () => { + mockPingDaemon.mockResolvedValue(UNREACHABLE); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.sock', { force: true }); + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('Removing stale socket'), + ); + }); + + it('surfaces non-ENOENT errors from reading the existing PID file during pre-flight', async () => { + mockReadFile.mockRejectedValue( + Object.assign(new Error('read denied'), { code: 'EACCES' }), + ); + mockCreateWallet.mockResolvedValue(createMockWallet()); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('read denied'), + ); + expect(process.exitCode).toBe(1); + }); + + it('treats a malformed PID file as having no PID (takes over the slot)', async () => { + mockReadFile.mockResolvedValueOnce('not-a-number\n'); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // Pre-flight treated the file as if no PID was present (existingPid === undefined), + // pinged, found nothing, then removed the stale socket. No error. + expect(process.exitCode).toBeUndefined(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.sock', { force: true }); + }); + + it('clears stale PID + socket files when the recorded daemon is no longer responsive', async () => { + // PID file is present and pingDaemon returns absent → take over. + mockReadFile.mockResolvedValueOnce('9999\n12345\n'); + mockPingDaemon.mockResolvedValue(ABSENT); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.sock', { force: true }); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/daemon.pid', + expect.any(String), + { flag: 'wx' }, + ); + }); + it('cleans up wallet, store, and PID file when server fails to start', async () => { const result = createMockWallet(); mockCreateWallet.mockResolvedValue(result); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + // Second readFile call (ownership check during cleanup) sees the PID + // file we just wrote — return matching contents so removal proceeds. + mockReadFile + .mockRejectedValueOnce(enoent()) // pre-flight readPidFromFile + .mockImplementation(async () => { + const lastWrite = mockWriteFile.mock.calls.at(-1)?.[1]; + return typeof lastWrite === 'string' ? lastWrite : ''; + }); await importDaemonEntry(); @@ -199,7 +314,25 @@ describe('daemon-entry', () => { expect(process.exitCode).toBe(1); }); - it('still cleans up PID and store when wallet.destroy fails during error cleanup', async () => { + it('does not remove the PID file during cleanup if its contents no longer match', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + // Pre-flight finds no PID file (ENOENT). Cleanup readFile returns + // unrelated contents (a different daemon's PID file) — must not rm. + mockReadFile + .mockRejectedValueOnce(enoent()) + .mockResolvedValueOnce('99999\n9999999\n'); + + await importDaemonEntry(); + + expect(mockRm).not.toHaveBeenCalledWith('/tmp/daemon.pid', { + force: true, + }); + expect(process.exitCode).toBe(1); + }); + + it('still cleans up wallet/store when wallet.destroy fails during error cleanup', async () => { const result = createMockWallet(); (result.wallet.destroy as jest.Mock).mockRejectedValue( new Error('destroy failed'), @@ -210,7 +343,6 @@ describe('daemon-entry', () => { await importDaemonEntry(); expect(result.store.close).toHaveBeenCalled(); - expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); expect(process.exitCode).toBe(1); }); @@ -228,7 +360,26 @@ describe('daemon-entry', () => { '/tmp/daemon.log', expect.stringContaining('store.close() failed during cleanup'), ); - expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(process.exitCode).toBe(1); + }); + + it('logs and continues when ownership-aware PID removal throws during error cleanup', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + // Force ownership check (readFile) to throw non-ENOENT so removeOwnedPidFile rejects. + mockReadFile + .mockRejectedValueOnce(enoent()) + .mockRejectedValueOnce( + Object.assign(new Error('read denied'), { code: 'EACCES' }), + ); + + await importDaemonEntry(); + + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('Failed to remove PID file during cleanup'), + ); expect(process.exitCode).toBe(1); }); @@ -238,7 +389,6 @@ describe('daemon-entry', () => { await importDaemonEntry(); - // Extract the handlers passed to startRpcSocketServer const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; const { handlers } = callArgs; const status = (await handlers.getStatus(null)) as { @@ -256,7 +406,6 @@ describe('daemon-entry', () => { await importDaemonEntry(); - // makeLogger writes via appendFile to the log path expect(mockAppendFile).toHaveBeenCalledWith( '/tmp/daemon.log', expect.stringContaining('Starting daemon...'), @@ -270,7 +419,6 @@ describe('daemon-entry', () => { await importDaemonEntry(); - // Flush the appendFile rejection handler for (let i = 0; i < 10; i++) { await new Promise((resolve) => process.nextTick(resolve)); } @@ -406,8 +554,17 @@ describe('daemon-entry', () => { mockCreateWallet.mockResolvedValue(result); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); - // rm rejects but cleanup should not fail - mockRm.mockRejectedValue(new Error('rm failed')); + mockReadFile + .mockRejectedValueOnce(enoent()) + .mockImplementation(async () => { + const lastWrite = mockWriteFile.mock.calls.at(-1)?.[1]; + return typeof lastWrite === 'string' ? lastWrite : ''; + }); + // Pre-flight rm (claimDaemonSlot) should succeed; shutdown-time rm should + // reject so we can verify the failure is logged rather than thrown. + mockRm + .mockResolvedValueOnce(undefined) + .mockRejectedValue(new Error('rm failed')); await importDaemonEntry(); @@ -418,6 +575,10 @@ describe('daemon-entry', () => { expect(handle.close).toHaveBeenCalled(); expect(result.wallet.destroy).toHaveBeenCalled(); + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('Failed to remove socket file'), + ); }); it('handles rm rejection in error cleanup path gracefully', async () => { @@ -435,10 +596,16 @@ describe('daemon-entry', () => { mockCreateWallet.mockResolvedValue(result); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); + // Echo the written PID contents back for ownership check. + mockReadFile + .mockRejectedValueOnce(enoent()) + .mockImplementation(async () => { + const lastWrite = mockWriteFile.mock.calls.at(-1)?.[1]; + return typeof lastWrite === 'string' ? lastWrite : ''; + }); await importDaemonEntry(); - // Extract the onShutdown callback const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; const onShutdown = callArgs.onShutdown as () => Promise; diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index cfe16be3840..10ddbf16992 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -1,11 +1,13 @@ import type { Json } from '@metamask/utils'; import { mkdirSync } from 'node:fs'; -import { appendFile, rm, writeFile } from 'node:fs/promises'; +import { appendFile, readFile, rm, writeFile } from 'node:fs/promises'; +import { pingDaemon } from './daemon-client'; import { getDaemonPaths } from './paths'; import { startRpcSocketServer } from './rpc-socket-server'; import type { RpcSocketServerHandle } from './rpc-socket-server'; import type { DaemonStatusInfo, RpcHandlerMap } from './types'; +import { isErrorWithCode } from './utils'; import { createWallet } from './wallet-factory'; const startTime = Date.now(); @@ -52,6 +54,13 @@ async function main(): Promise { const log = makeLogger(logPath); log('Starting daemon...'); + // Pre-flight: refuse to take over if a responsive daemon already owns this + // socket. If the existing PID file is stale (or the socket is dead), clean + // it up so the exclusive PID-file write below has a chance to succeed. + await claimDaemonSlot(pidPath, socketPath, log); + + const pidFileContents = `${process.pid}\n${startTime}\n`; + const { wallet, store } = await createWallet({ databasePath: dbPath, infuraProjectId, @@ -65,8 +74,9 @@ async function main(): Promise { uptime: Math.floor((Date.now() - startTime) / 1000), }), // Arbitrary messenger dispatch is intentional: the CLI exposes the full - // messenger surface over a local Unix socket. Access control is enforced - // at the socket level (only local users can connect). + // messenger surface over a Unix socket inside the per-user oclif data + // directory. Anything that can open that path can call into the wallet — + // no in-process auth check is performed. call: async (params) => { if (!Array.isArray(params) || typeof params[0] !== 'string') { throw new Error('Expected params to be an array with an action name'); @@ -80,7 +90,10 @@ async function main(): Promise { let handle: RpcSocketServerHandle; try { - await writeFile(pidPath, String(process.pid)); + // Exclusive create — if another daemon raced us between claimDaemonSlot + // and here, this fails with EEXIST and we abort rather than orphan the + // sibling daemon's PID file. + await writeFile(pidPath, pidFileContents, { flag: 'wx' }); handle = await startRpcSocketServer({ socketPath, @@ -98,9 +111,13 @@ async function main(): Promise { } catch (closeError) { log(`store.close() failed during cleanup: ${String(closeError)}`); } - await rm(pidPath, { force: true }).catch((rmError: unknown) => { - log(`Failed to remove PID file during cleanup: ${String(rmError)}`); - }); + // Only remove the PID file if it's still ours (we may have lost the race + // and the file now belongs to another daemon). + await removeOwnedPidFile(pidPath, pidFileContents).catch( + (rmError: unknown) => { + log(`Failed to remove PID file during cleanup: ${String(rmError)}`); + }, + ); throw error; } @@ -134,9 +151,11 @@ async function main(): Promise { log(`store.close() failed: ${String(closeError)}`); } await Promise.all([ - rm(pidPath, { force: true }).catch((rmError: unknown) => { - log(`Failed to remove PID file: ${String(rmError)}`); - }), + removeOwnedPidFile(pidPath, pidFileContents).catch( + (rmError: unknown) => { + log(`Failed to remove PID file: ${String(rmError)}`); + }, + ), rm(socketPath, { force: true }).catch((rmError: unknown) => { log(`Failed to remove socket file: ${String(rmError)}`); }), @@ -156,6 +175,100 @@ async function main(): Promise { }); } +/** + * Refuse to start if a responsive daemon already owns the socket. Otherwise + * clear any stale PID/socket files so the exclusive PID-file write can + * proceed. + * + * @param pidPath - The PID file path. + * @param socketPath - The socket path. + * @param log - Logger for diagnostic messages. + */ +async function claimDaemonSlot( + pidPath: string, + socketPath: string, + log: (message: string) => void, +): Promise { + const existingPid = await readPidFromFile(pidPath); + if (existingPid === undefined) { + // No PID file. Still possible the socket file exists from a crashed run; + // ping it to confirm before removing. + const ping = await pingDaemon(socketPath); + if (ping.status === 'responsive') { + throw new Error( + `A daemon is already running on ${socketPath} (no PID file present)`, + ); + } + if (ping.status === 'unreachable') { + log(`Removing stale socket at ${socketPath} (${ping.error.message})`); + } + await rm(socketPath, { force: true }); + return; + } + + const ping = await pingDaemon(socketPath); + if (ping.status === 'responsive') { + throw new Error( + `A daemon is already running (pid ${existingPid}, socket ${socketPath})`, + ); + } + + log(`Removing stale daemon state (recorded pid ${existingPid}).`); + await Promise.all([ + rm(pidPath, { force: true }), + rm(socketPath, { force: true }), + ]); +} + +/** + * Read the PID number from a PID file. Returns undefined when the file is + * missing or malformed. Reads only the first line so files written with + * `${pid}\n${startTime}\n` format are parsed correctly. + * + * @param pidPath - Path to the PID file. + * @returns The PID, or undefined if missing or unparseable. + */ +async function readPidFromFile(pidPath: string): Promise { + let contents: string; + try { + contents = await readFile(pidPath, 'utf-8'); + } catch (error: unknown) { + if (isErrorWithCode(error, 'ENOENT')) { + return undefined; + } + throw error; + } + // String.prototype.split always returns at least one element, so [0] is safe. + const pid = Number(contents.split('\n')[0].trim()); + return Number.isInteger(pid) && pid > 0 ? pid : undefined; +} + +/** + * Remove the PID file only if it still contains our exact contents. Guards + * against a racing daemon's PID file being removed by this daemon during + * cleanup. + * + * @param pidPath - Path to the PID file. + * @param expectedContents - The contents we wrote when claiming the slot. + */ +async function removeOwnedPidFile( + pidPath: string, + expectedContents: string, +): Promise { + let actual: string; + try { + actual = await readFile(pidPath, 'utf-8'); + } catch (error: unknown) { + if (isErrorWithCode(error, 'ENOENT')) { + return; + } + throw error; + } + if (actual === expectedContents) { + await rm(pidPath, { force: true }); + } +} + /** * Create a simple file logger. * diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts index 2e42e7ae065..fdff2d289f9 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -24,29 +24,85 @@ const CONFIG: DaemonSpawnConfig = { packageRoot: '/pkg', }; +const ABSENT = { status: 'absent' as const }; +const RESPONSIVE = { status: 'responsive' as const }; +const UNREACHABLE = { + status: 'unreachable' as const, + error: new Error('wedged'), +}; + +/** + * Build a minimal mock for the `ChildProcess` returned by `spawn`. The `on` + * handler captures `'exit'`/`'error'` listeners so tests can fire them. + */ +type SpawnMock = { + unref: jest.Mock; + on: jest.Mock; + fireExit: (code: number | null, signal: NodeJS.Signals | null) => void; +}; + +/** + * Build a fresh spawn mock and wire it as the return value of `mockSpawn`. + * + * @returns The captured handles so tests can fire lifecycle events. + */ +function setupSpawnMock(): SpawnMock { + const listeners = new Map void>(); + const on = jest.fn((event: string, handler: (...args: unknown[]) => void) => { + listeners.set(event, handler); + }); + const result: SpawnMock = { + unref: jest.fn(), + on, + fireExit: (code, signal) => { + listeners.get('exit')?.(code, signal); + }, + }; + mockSpawn.mockReturnValue(result as never); + return result; +} + describe('ensureDaemon', () => { beforeEach(() => { + jest.resetAllMocks(); jest.spyOn(process.stderr, 'write').mockImplementation(() => true); mockGetDaemonPaths.mockReturnValue({ socketPath: '/tmp/test.sock', pidPath: '/tmp/test.pid', logPath: '/tmp/test.log', + dbPath: '/tmp/wallet.db', }); - mockSpawn.mockReturnValue({ - unref: jest.fn(), - on: jest.fn(), - } as never); + setupSpawnMock(); }); - it('returns immediately if daemon is already running', async () => { - mockPingDaemon.mockResolvedValue(true); + afterEach(() => { + jest.useRealTimers(); + }); - await ensureDaemon(CONFIG); + it('returns already-running when a responsive daemon already exists', async () => { + mockPingDaemon.mockResolvedValue(RESPONSIVE); + + const result = await ensureDaemon(CONFIG); + expect(result).toStrictEqual({ + state: 'already-running', + socketPath: '/tmp/test.sock', + }); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('refuses to start when the socket exists but is unreachable', async () => { + mockPingDaemon.mockResolvedValue(UNREACHABLE); + + await expect(ensureDaemon(CONFIG)).rejects.toThrow( + /a daemon socket already exists.*unresponsive/u, + ); expect(mockSpawn).not.toHaveBeenCalled(); }); it('spawns daemon as detached child with correct env vars', async () => { - mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockPingDaemon + .mockResolvedValueOnce(ABSENT) + .mockResolvedValueOnce(RESPONSIVE); mockExistsSync.mockReturnValue(true); await ensureDaemon(CONFIG); @@ -59,6 +115,7 @@ describe('ensureDaemon', () => { stdio: 'ignore', env: expect.objectContaining({ MM_DAEMON_DATA_DIR: '/tmp/data', + MM_DAEMON_SOCKET_PATH: '/tmp/test.sock', INFURA_PROJECT_ID: 'test-key', MM_WALLET_PASSWORD: 'test-pass', MM_WALLET_SRP: @@ -68,8 +125,24 @@ describe('ensureDaemon', () => { ); }); + it('returns started when the spawned daemon becomes responsive', async () => { + mockPingDaemon + .mockResolvedValueOnce(ABSENT) + .mockResolvedValueOnce(RESPONSIVE); + mockExistsSync.mockReturnValue(true); + + const result = await ensureDaemon(CONFIG); + + expect(result).toStrictEqual({ + state: 'started', + socketPath: '/tmp/test.sock', + }); + }); + it('uses dist entry when it exists', async () => { - mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockPingDaemon + .mockResolvedValueOnce(ABSENT) + .mockResolvedValueOnce(RESPONSIVE); mockExistsSync.mockReturnValue(true); await ensureDaemon(CONFIG); @@ -79,7 +152,9 @@ describe('ensureDaemon', () => { }); it('falls back to src entry with tsx when dist missing', async () => { - mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockPingDaemon + .mockResolvedValueOnce(ABSENT) + .mockResolvedValueOnce(RESPONSIVE); mockExistsSync.mockReturnValue(false); await ensureDaemon(CONFIG); @@ -94,10 +169,10 @@ describe('ensureDaemon', () => { it('polls until daemon is ready', async () => { mockPingDaemon - .mockResolvedValueOnce(false) // initial check - .mockResolvedValueOnce(false) // poll 1 - .mockResolvedValueOnce(false) // poll 2 - .mockResolvedValueOnce(true); // poll 3 + .mockResolvedValueOnce(ABSENT) // initial check + .mockResolvedValueOnce(ABSENT) // poll 1 + .mockResolvedValueOnce(ABSENT) // poll 2 + .mockResolvedValueOnce(RESPONSIVE); // poll 3 mockExistsSync.mockReturnValue(true); await ensureDaemon(CONFIG); @@ -108,14 +183,12 @@ describe('ensureDaemon', () => { it('throws after timeout when daemon never responds', async () => { jest.useFakeTimers(); - mockPingDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue(ABSENT); mockExistsSync.mockReturnValue(true); const promise = ensureDaemon(CONFIG); - // Attach rejection handler before advancing timers to avoid unhandled rejection const rejection = promise.catch((thrown: unknown) => thrown); - // Advance past all 300 polls (100ms each = 30s) await jest.advanceTimersByTimeAsync(30_100); const thrownError = await rejection; expect(thrownError).toBeInstanceOf(Error); @@ -125,21 +198,54 @@ describe('ensureDaemon', () => { jest.useRealTimers(); }); - it('calls unref on spawned child and registers error handler', async () => { - const unref = jest.fn(); - const on = jest.fn(); - mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + it('throws early when the child process exits during the readiness poll', async () => { + mockPingDaemon.mockResolvedValue(ABSENT); + mockExistsSync.mockReturnValue(true); + // Fire exit at the moment the daemon-spawn code registers the listener, + // so the very first poll iteration sees exitInfo set. + const on = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + if (event === 'exit') { + handler(1, null); + } + }, + ); + mockSpawn.mockReturnValue({ unref: jest.fn(), on } as never); + + jest.useFakeTimers(); + const promise = ensureDaemon(CONFIG); + const rejection = promise.catch((thrown: unknown) => thrown); + await jest.advanceTimersByTimeAsync(200); + + const thrownError = await rejection; + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toContain( + 'Daemon process exited during startup', + ); + expect((thrownError as Error).message).toContain('code=1'); + expect((thrownError as Error).message).toContain('/tmp/test.log'); + }); + + it('calls unref on spawned child and registers error + exit handlers', async () => { + mockPingDaemon + .mockResolvedValueOnce(ABSENT) + .mockResolvedValueOnce(RESPONSIVE); mockExistsSync.mockReturnValue(true); - mockSpawn.mockReturnValue({ unref, on } as never); + const spawnMock = setupSpawnMock(); await ensureDaemon(CONFIG); - expect(unref).toHaveBeenCalled(); - expect(on).toHaveBeenCalledWith('error', expect.any(Function)); + expect(spawnMock.unref).toHaveBeenCalled(); + expect(spawnMock.on).toHaveBeenCalledWith('error', expect.any(Function)); + expect(spawnMock.on).toHaveBeenCalledWith('exit', expect.any(Function)); }); it('writes spawn errors to stderr', async () => { - const unref = jest.fn(); + mockPingDaemon + .mockResolvedValueOnce(ABSENT) + .mockResolvedValueOnce(RESPONSIVE); + mockExistsSync.mockReturnValue(true); + let errorHandler: ((error: Error) => void) | undefined; const on = jest.fn( (event: string, handler: (error: Error) => void): void => { @@ -148,9 +254,7 @@ describe('ensureDaemon', () => { } }, ); - mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); - mockExistsSync.mockReturnValue(true); - mockSpawn.mockReturnValue({ unref, on } as never); + mockSpawn.mockReturnValue({ unref: jest.fn(), on } as never); await ensureDaemon(CONFIG); errorHandler?.(new Error('spawn ENOENT')); diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index 5a63815d5d7..b398aac0d3b 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -10,15 +10,46 @@ const POLL_INTERVAL_MS = 100; const MAX_POLLS = 300; // 30 seconds /** - * Ensure the daemon is running. If it is not, spawn it as a detached process - * and wait until the socket becomes responsive. + * Outcome of {@link ensureDaemon}. + * + * - `'already-running'`: a responsive daemon was found at the configured + * socket path. The supplied flags (`infuraProjectId`, `password`, `srp`) + * were NOT applied to that daemon; the caller should surface this so a + * user who is trying to change them isn't silently ignored. + * - `'started'`: a new daemon was spawned and is now responsive. + */ +export type EnsureDaemonResult = { + state: 'already-running' | 'started'; + socketPath: string; +}; + +/** + * Ensure the daemon is running. If a responsive daemon already exists, return + * `'already-running'` (caller decides how to surface that). Otherwise spawn + * one as a detached process and wait until the socket becomes responsive. + * + * Refuses to spawn when the socket exists but is unreachable (wedged/foreign + * daemon) — taking over would orphan the existing process and corrupt its + * PID file. * * @param config - Spawn configuration. + * @returns The state of the daemon and the socket path it's listening on. */ -export async function ensureDaemon(config: DaemonSpawnConfig): Promise { +export async function ensureDaemon( + config: DaemonSpawnConfig, +): Promise { const { socketPath } = getDaemonPaths(config.dataDir); - if (await pingDaemon(socketPath)) { - return; + + const initialPing = await pingDaemon(socketPath); + if (initialPing.status === 'responsive') { + return { state: 'already-running', socketPath }; + } + if (initialPing.status === 'unreachable') { + throw new Error( + `Refusing to start: a daemon socket already exists at ${socketPath} but is unresponsive. ` + + `Run \`mm daemon stop\` (or \`mm daemon purge\`) before starting a new daemon. ` + + `(${initialPing.error.message})`, + ); } process.stderr.write('Starting daemon...\n'); @@ -31,21 +62,35 @@ export async function ensureDaemon(config: DaemonSpawnConfig): Promise { env: { ...process.env, MM_DAEMON_DATA_DIR: config.dataDir, + MM_DAEMON_SOCKET_PATH: socketPath, INFURA_PROJECT_ID: config.infuraProjectId, MM_WALLET_PASSWORD: config.password, MM_WALLET_SRP: config.srp, }, }); + + let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = + null; child.on('error', (error) => { process.stderr.write(`Failed to spawn daemon process: ${String(error)}\n`); }); + child.on('exit', (code, signal) => { + exitInfo = { code, signal }; + }); child.unref(); for (let i = 0; i < MAX_POLLS; i++) { await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - if (await pingDaemon(socketPath)) { + if (exitInfo !== null) { + throw new Error( + `Daemon process exited during startup (code=${String(exitInfo.code)}, signal=${String(exitInfo.signal)}). ` + + `Check the daemon log at ${getDaemonPaths(config.dataDir).logPath}.`, + ); + } + const ping = await pingDaemon(socketPath); + if (ping.status === 'responsive') { process.stderr.write('Daemon ready.\n'); - return; + return { state: 'started', socketPath }; } } diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts index 48e276eeed7..59e1b7f02fe 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.test.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -16,14 +16,22 @@ const mockIsProcessAlive = jest.mocked(isProcessAlive); const mockSendSignal = jest.mocked(sendSignal); const mockWaitFor = jest.mocked(waitFor); +const ABSENT = { status: 'absent' as const }; +const RESPONSIVE = { status: 'responsive' as const }; +const UNREACHABLE = { + status: 'unreachable' as const, + error: new Error('refused'), +}; + describe('stopDaemon', () => { beforeEach(() => { mockRm.mockResolvedValue(undefined); + mockIsProcessAlive.mockReturnValue(false); }); it('returns true when daemon is not running (no PID file)', async () => { mockReadPidFile.mockResolvedValue(undefined); - mockPingDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue(ABSENT); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); @@ -31,24 +39,37 @@ describe('stopDaemon', () => { it('cleans up stale PID file when daemon is not running', async () => { mockReadPidFile.mockResolvedValue(123); - mockIsProcessAlive.mockReturnValue(false); - mockPingDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue(ABSENT); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); expect(mockRm).toHaveBeenCalledWith('/tmp/test.pid', { force: true }); + // Critically: do NOT signal the recorded PID when the socket is absent + // (PID may have been recycled to an unrelated process). + expect(mockSendSignal).not.toHaveBeenCalled(); + }); + + it('does not signal the recorded PID when the socket is absent even if isProcessAlive would say true', async () => { + mockReadPidFile.mockResolvedValue(123); + mockPingDaemon.mockResolvedValue(ABSENT); + // isProcessAlive should never be invoked in this branch, but guard the + // contract by also asserting no signal even if it would return true. + mockIsProcessAlive.mockReturnValue(true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockSendSignal).not.toHaveBeenCalled(); }); it('stops daemon via graceful RPC shutdown', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(true); + mockPingDaemon.mockResolvedValue(RESPONSIVE); mockSendCommand.mockResolvedValue({ jsonrpc: '2.0', id: '1', result: { status: 'shutting down' }, }); - // Invoke the check callback for coverage, then return true mockWaitFor.mockImplementation(async (check) => { await check(); return true; @@ -71,14 +92,13 @@ describe('stopDaemon', () => { it('falls through to SIGTERM when graceful shutdown times out', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(true); + mockPingDaemon.mockResolvedValue(RESPONSIVE); mockSendCommand.mockResolvedValue({ jsonrpc: '2.0', id: '1', result: null, }); mockSendSignal.mockReturnValue(true); - // First waitFor (graceful) invokes cb and fails, second (SIGTERM) invokes cb and succeeds mockWaitFor .mockImplementationOnce(async (check) => { await check(); @@ -97,14 +117,13 @@ describe('stopDaemon', () => { it('falls through to SIGKILL when SIGTERM times out', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(true); + mockPingDaemon.mockResolvedValue(RESPONSIVE); mockSendCommand.mockResolvedValue({ jsonrpc: '2.0', id: '1', result: null, }); mockSendSignal.mockReturnValue(true); - // All three waitFor calls invoke check, graceful + SIGTERM fail, SIGKILL succeeds mockWaitFor .mockImplementationOnce(async (check) => { await check(); @@ -127,7 +146,7 @@ describe('stopDaemon', () => { it('returns false when all strategies fail', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(true); + mockPingDaemon.mockResolvedValue(RESPONSIVE); mockSendCommand.mockResolvedValue({ jsonrpc: '2.0', id: '1', @@ -140,10 +159,23 @@ describe('stopDaemon', () => { expect(result).toBe(false); }); + it('skips graceful shutdown when the socket is unreachable and signals directly', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(UNREACHABLE); + mockSendSignal.mockReturnValue(true); + mockWaitFor.mockResolvedValueOnce(true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockSendCommand).not.toHaveBeenCalled(); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGTERM'); + }); + it('treats ESRCH on SIGTERM as stopped', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue(UNREACHABLE); mockSendSignal.mockReturnValue(false); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); @@ -153,8 +185,7 @@ describe('stopDaemon', () => { it('treats ESRCH on SIGKILL as stopped', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(false); - // SIGTERM signal sent but process doesn't die, SIGKILL finds it gone + mockPingDaemon.mockResolvedValue(UNREACHABLE); mockSendSignal.mockReturnValueOnce(true).mockReturnValueOnce(false); mockWaitFor.mockResolvedValueOnce(false); @@ -166,7 +197,7 @@ describe('stopDaemon', () => { it('falls through to SIGKILL when SIGTERM throws EPERM', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue(UNREACHABLE); mockSendSignal .mockImplementationOnce(() => { throw Object.assign(new Error('eperm'), { code: 'EPERM' }); @@ -182,7 +213,7 @@ describe('stopDaemon', () => { it('returns false when both SIGTERM and SIGKILL throw EPERM', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue(UNREACHABLE); mockSendSignal.mockImplementation(() => { throw Object.assign(new Error('eperm'), { code: 'EPERM' }); }); @@ -191,14 +222,79 @@ describe('stopDaemon', () => { expect(result).toBe(false); }); - it('treats sendCommand error as socket unresponsive', async () => { + it('treats sendCommand error as graceful shutdown failure and falls through', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(true); + mockPingDaemon.mockResolvedValue(RESPONSIVE); mockSendCommand.mockRejectedValue(new Error('socket error')); mockWaitFor.mockResolvedValue(true); - const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + const log = jest.fn(); + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid', log); + expect(result).toBe(true); + expect(log).toHaveBeenCalledWith( + expect.stringContaining('Graceful shutdown request failed'), + ); + }); + + it('logs rather than throws when post-stop cleanup of the PID file fails', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(RESPONSIVE); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + mockWaitFor.mockResolvedValue(true); + mockRm.mockImplementation((path) => + path === '/tmp/test.pid' + ? Promise.reject(new Error('pid rm failed')) + : Promise.resolve(undefined), + ); + + const log = jest.fn(); + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid', log); + expect(result).toBe(true); + expect(log).toHaveBeenCalledWith( + expect.stringContaining('Failed to remove PID file'), + ); + }); + + it('logs rather than throws when post-stop cleanup of the socket file fails', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(RESPONSIVE); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + mockWaitFor.mockResolvedValue(true); + mockRm.mockImplementation((path) => + path === '/tmp/test.sock' + ? Promise.reject(new Error('socket rm failed')) + : Promise.resolve(undefined), + ); + + const log = jest.fn(); + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid', log); + expect(result).toBe(true); + expect(log).toHaveBeenCalledWith( + expect.stringContaining('Failed to remove socket file'), + ); + }); + + it('logs rather than throws when stale-PID cleanup fails', async () => { + mockReadPidFile.mockResolvedValue(123); + mockPingDaemon.mockResolvedValue(ABSENT); + mockRm.mockRejectedValue(new Error('rm denied')); + + const log = jest.fn(); + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid', log); expect(result).toBe(true); + expect(log).toHaveBeenCalledWith( + expect.stringContaining('Failed to remove PID file'), + ); }); }); diff --git a/packages/wallet-cli/src/daemon/stop-daemon.ts b/packages/wallet-cli/src/daemon/stop-daemon.ts index dc5f0c27901..baba8cf04dd 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.ts @@ -7,6 +7,12 @@ import { isProcessAlive, readPidFile, sendSignal, waitFor } from './utils'; * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if * the socket is unresponsive, and escalates to SIGKILL if SIGTERM is ignored. * + * Signals are only sent if we have positive evidence that this PID belongs + * to our daemon — namely, the socket file at `socketPath` existed when we + * pinged (`responsive` or `unreachable`). When the socket is `absent` we + * decline to signal the recorded PID, because long-running workstations can + * recycle PIDs to unrelated processes; we just clean up the stale PID file. + * * @param socketPath - The daemon socket path. * @param pidPath - The daemon PID file path. * @param log - Optional logging function for status messages. @@ -18,13 +24,15 @@ export async function stopDaemon( log?: (message: string) => void, ): Promise { const pid = await readPidFile(pidPath); - const processAlive = pid !== undefined && isProcessAlive(pid); - const socketResponsive = await pingDaemon(socketPath); + const ping = await pingDaemon(socketPath); + const socketObserved = + ping.status === 'responsive' || ping.status === 'unreachable'; + const processAlive = + pid !== undefined && socketObserved && isProcessAlive(pid); - if (!socketResponsive && !processAlive) { - if (pid !== undefined) { - await rm(pidPath, { force: true }); - } + if (!socketObserved && !processAlive) { + // No live daemon evidence. Just remove the stale PID file if any. + await cleanupFile(pidPath, 'PID file', log); return true; } @@ -33,17 +41,21 @@ export async function stopDaemon( let stopped = false; // Strategy 1: Graceful socket-based shutdown. - if (socketResponsive) { + if (ping.status === 'responsive') { try { await sendCommand({ socketPath, method: 'shutdown' }); } catch (error) { log?.(`Graceful shutdown request failed: ${String(error)}`); } - stopped = await waitFor(async () => !(await pingDaemon(socketPath)), 5_000); + stopped = await waitFor( + async () => (await pingDaemon(socketPath)).status !== 'responsive', + 5_000, + ); } - // Strategy 2: SIGTERM. - if (!stopped && pid !== undefined) { + // Strategy 2: SIGTERM. Only signal when we have evidence the socket + // belongs to a live process (socketObserved && processAlive). + if (!stopped && processAlive && pid !== undefined) { try { if (sendSignal(pid, 'SIGTERM')) { stopped = await waitFor(() => !isProcessAlive(pid), 5_000); @@ -56,7 +68,7 @@ export async function stopDaemon( } // Strategy 3: SIGKILL. - if (!stopped && pid !== undefined) { + if (!stopped && processAlive && pid !== undefined) { try { if (sendSignal(pid, 'SIGKILL')) { stopped = await waitFor(() => !isProcessAlive(pid), 2_000); @@ -69,12 +81,28 @@ export async function stopDaemon( } if (stopped) { - await Promise.all([ - rm(pidPath, { force: true }), - rm(socketPath, { force: true }), - ]); + await cleanupFile(pidPath, 'PID file', log); + await cleanupFile(socketPath, 'socket file', log); log?.('Daemon stopped.'); } return stopped; } + +/** + * Remove a file best-effort, logging any failure rather than letting it + * propagate. ENOENT is silently ignored via `force: true`. + * + * @param path - The file path to remove. + * @param label - Human-readable label for log messages. + * @param log - Optional log sink. + */ +async function cleanupFile( + path: string, + label: string, + log: ((message: string) => void) | undefined, +): Promise { + await rm(path, { force: true }).catch((error: unknown) => { + log?.(`Failed to remove ${label}: ${String(error)}`); + }); +} diff --git a/packages/wallet-cli/src/daemon/utils.test.ts b/packages/wallet-cli/src/daemon/utils.test.ts index 78b678078f4..db8b7cc4d9b 100644 --- a/packages/wallet-cli/src/daemon/utils.test.ts +++ b/packages/wallet-cli/src/daemon/utils.test.ts @@ -85,12 +85,26 @@ describe('isProcessAlive', () => { expect(isProcessAlive(123)).toBe(true); }); - it('returns false on other errors', () => { + it('returns false on ESRCH (process gone)', () => { jest.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('esrch'), { code: 'ESRCH' }); }); expect(isProcessAlive(123)).toBe(false); }); + + it('rethrows unknown errors instead of guessing the process is dead', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('einval'), { code: 'EINVAL' }); + }); + expect(() => isProcessAlive(123)).toThrow('einval'); + }); + + it('rethrows non-system errors', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('unexpected'); + }); + expect(() => isProcessAlive(123)).toThrow('unexpected'); + }); }); describe('sendSignal', () => { diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts index 3e1db9cdc04..f26e20d989e 100644 --- a/packages/wallet-cli/src/daemon/utils.ts +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -10,7 +10,6 @@ import { readFile } from 'node:fs/promises'; */ export function isErrorWithCode(error: unknown, code: string): boolean { return ( - // TODO: use Error.isError() error instanceof Error && hasProperty(error, 'code') && error.code === code ); } @@ -38,6 +37,11 @@ export async function readPidFile( /** * Check whether a process is alive by sending signal 0. * + * Treats `ESRCH` as "process is gone", `EPERM` as "process exists but we + * cannot signal it" (still alive from our perspective), and rethrows + * anything else so the caller can surface unexpected failures rather than + * silently assuming the process is dead. + * * @param pid - The process ID to check. * @returns True if the process exists. */ @@ -46,10 +50,13 @@ export function isProcessAlive(pid: number): boolean { process.kill(pid, 0); return true; } catch (error: unknown) { + if (isErrorWithCode(error, 'ESRCH')) { + return false; + } if (isErrorWithCode(error, 'EPERM')) { return true; } - return false; + throw error; } } From 6eae8aa178ba9242d21277647e38c271e966297e Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 12 May 2026 16:57:40 +0200 Subject: [PATCH 28/32] fix(wallet-cli): Address review-driven follow-ups to race-proofing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves issues surfaced by a second review of the daemon hardening: - Collapse `readPidFile` (utils.ts) and `readPidFromFile` (daemon-entry.ts) into one function in utils.ts that parses the first line. The previous commit changed the daemon to write `${pid}\n${startTime}\n` but the shared reader still did `Number(contents)`, which evaluated to `NaN` — silently disabling `stopDaemon`'s SIGTERM/SIGKILL fallbacks and the `daemon status` PID-mismatch warning. Added a round-trip test using the production format. - `claimDaemonSlot` now also rm's the PID file in the `existingPid === undefined` branch. A corrupt/truncated PID file from a crashed run would otherwise permanently block startup with EEXIST on the exclusive `wx` write. - `claimDaemonSlot` refuses to clobber when the socket is `unreachable` AND the recorded PID is still alive — mirrors `ensureDaemon`'s refusal so direct invocations of `daemon-entry` (or races against parent ping) can't orphan a sibling daemon either. - `daemon purge` no longer aborts when `stopDaemon` returns false on a daemon that isn't responsive — that's the exact state purge exists to recover from. It still refuses when a daemon IS responsive. - `packages/wallet/CHANGELOG.md` adds an `Added` entry for the `importSecretRecoveryPhrase` export introduced earlier on this branch. - Tightened JSDoc accuracy on `PingResult` (don't tell callers to refuse destructive action when stopDaemon legitimately signals on unreachable), on `stopDaemon` and `ensureDaemon` (replace "socket file exists" with "ping yielded non-ENOENT"), and on `createWallet` (both `password` and `srp` are unused on subsequent runs, not only the SRP). - New tests: EEXIST race, unreachable+alive refusal, corrupt-PID-file recovery, PID-file format round-trip. 186 wallet-cli tests pass at 100% coverage; 9 wallet tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wallet-cli/src/commands/daemon/purge.ts | 36 +++++-- .../wallet-cli/src/daemon/daemon-client.ts | 12 ++- .../src/daemon/daemon-entry.test.ts | 97 +++++++++++++++++-- .../wallet-cli/src/daemon/daemon-entry.ts | 70 ++++++------- .../wallet-cli/src/daemon/daemon-spawn.ts | 6 +- packages/wallet-cli/src/daemon/stop-daemon.ts | 10 +- packages/wallet-cli/src/daemon/utils.test.ts | 14 ++- packages/wallet-cli/src/daemon/utils.ts | 14 ++- .../wallet-cli/src/daemon/wallet-factory.ts | 6 +- packages/wallet/CHANGELOG.md | 4 + 10 files changed, 189 insertions(+), 80 deletions(-) diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts index b333e5cf26d..d1db9d7586f 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -1,6 +1,7 @@ import { Command, Flags } from '@oclif/core'; import { rm } from 'node:fs/promises'; +import { pingDaemon } from '../../daemon/daemon-client'; import { getDaemonPaths } from '../../daemon/paths'; import { stopDaemon } from '../../daemon/stop-daemon'; @@ -44,7 +45,23 @@ export default class DaemonPurge extends Command { ); if (!stopped) { - this.error('Refusing to delete state while the daemon is still running.'); + // `stopDaemon` returns false when it couldn't be sure the daemon + // exited — typically because the socket exists but the daemon never + // responded to signals, or because the PID file is stale and the + // socket is orphan. Purge is the user's escape hatch for exactly + // these states, so as long as the daemon is not currently + // responsive, we proceed with the deletion the user already + // confirmed. If the daemon IS responsive, we still refuse — that + // would risk corrupting live state. + const ping = await pingDaemon(paths.socketPath); + if (ping.status === 'responsive') { + this.error( + 'Refusing to delete state while the daemon is still responsive.', + ); + } + this.log( + 'Could not confirm clean shutdown; proceeding to delete state anyway.', + ); } // Whitelist only the daemon-owned files rather than rm'ing the entire @@ -52,15 +69,14 @@ export default class DaemonPurge extends Command { // files, future config). `force: true` makes ENOENT a no-op for any // file already removed by stopDaemon. await Promise.all( - [paths.pidPath, paths.socketPath, paths.logPath, paths.dbPath].map( - async (path) => rm(path, { force: true }), - ), - ); - // Remove the SQLite sidecar files too (WAL/SHM are created in WAL mode). - await Promise.all( - [`${paths.dbPath}-wal`, `${paths.dbPath}-shm`].map(async (path) => - rm(path, { force: true }), - ), + [ + paths.pidPath, + paths.socketPath, + paths.logPath, + paths.dbPath, + `${paths.dbPath}-wal`, + `${paths.dbPath}-shm`, + ].map(async (path) => rm(path, { force: true })), ); this.log('All daemon state deleted.'); diff --git a/packages/wallet-cli/src/daemon/daemon-client.ts b/packages/wallet-cli/src/daemon/daemon-client.ts index 42c2ecaac99..b96c7b806c9 100644 --- a/packages/wallet-cli/src/daemon/daemon-client.ts +++ b/packages/wallet-cli/src/daemon/daemon-client.ts @@ -101,11 +101,13 @@ export async function sendCommand({ * Outcome of a daemon health check. * * - `'responsive'`: the daemon answered a `getStatus` RPC. - * - `'absent'`: the socket file does not exist (ENOENT). No daemon present. - * - `'unreachable'`: the socket exists but cannot be queried (refused after - * retry, timeout, permission denied, parse error, etc.). Callers should - * refuse to take destructive action against an unreachable daemon — the - * process may still be alive. + * - `'absent'`: the socket connect attempt failed with `ENOENT`, i.e. no + * socket file exists at the path. No daemon present. + * - `'unreachable'`: any other non-success outcome (refused after retry, + * timeout, permission denied, parse error, id mismatch, etc.). The daemon + * may still be alive but is not responding. Callers should not silently + * take over the slot or assume the daemon is dead. User-initiated stop / + * purge flows may still escalate to signals against the recorded PID. */ export type PingResult = | { status: 'responsive' } diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 464bd2f575e..9da228b2566 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -5,6 +5,7 @@ import { pingDaemon } from './daemon-client'; import { getDaemonPaths } from './paths'; import { startRpcSocketServer } from './rpc-socket-server'; import type { RpcSocketServerHandle } from './rpc-socket-server'; +import { isProcessAlive } from './utils'; import { createWallet } from './wallet-factory'; jest.mock('node:fs'); @@ -12,6 +13,13 @@ jest.mock('node:fs/promises'); jest.mock('./daemon-client'); jest.mock('./paths'); jest.mock('./rpc-socket-server'); +jest.mock('./utils', () => { + const actual = jest.requireActual('./utils'); + return { + ...actual, + isProcessAlive: jest.fn(), + }; +}); jest.mock('./wallet-factory'); const mockMkdirSync = jest.mocked(mkdirSync); @@ -23,6 +31,7 @@ const mockPingDaemon = jest.mocked(pingDaemon); const mockGetDaemonPaths = jest.mocked(getDaemonPaths); const mockStartRpcSocketServer = jest.mocked(startRpcSocketServer); const mockCreateWallet = jest.mocked(createWallet); +const mockIsProcessAlive = jest.mocked(isProcessAlive); const ORIGINAL_ENV = process.env; @@ -99,6 +108,7 @@ describe('daemon-entry', () => { mockRm.mockResolvedValue(undefined); mockAppendFile.mockResolvedValue(undefined); mockPingDaemon.mockResolvedValue(ABSENT); + mockIsProcessAlive.mockReturnValue(false); }); afterEach(() => { @@ -314,21 +324,94 @@ describe('daemon-entry', () => { expect(process.exitCode).toBe(1); }); + it('aborts when another daemon wins the exclusive PID-file write race', async () => { + // Simulate two daemons reaching the wx write nearly simultaneously: pre-flight + // sees no PID file (ENOENT), but writeFile rejects with EEXIST because a + // sibling already claimed the slot. The cleanup ownership check must NOT + // remove the sibling's PID file. + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + const eexist = Object.assign(new Error('already exists'), { + code: 'EEXIST', + }); + mockWriteFile.mockRejectedValue(eexist); + mockReadFile + .mockRejectedValueOnce(enoent()) // pre-flight readPidFile: no file yet + .mockResolvedValueOnce('99999\n9999999\n'); // ownership-check sees sibling + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('already exists'), + ); + expect(process.exitCode).toBe(1); + expect(result.wallet.destroy).toHaveBeenCalled(); + expect(result.store.close).toHaveBeenCalled(); + // Critical: removeOwnedPidFile saw the sibling's contents and refused + // to delete. The only rm of pidPath should be the pre-flight cleanup + // call (which is a no-op when the file doesn't exist yet). + const pidRmCalls = mockRm.mock.calls.filter( + ([path]) => path === '/tmp/daemon.pid', + ); + expect(pidRmCalls).toHaveLength(1); + }); + + it('refuses to take over an unreachable socket whose recorded PID is alive', async () => { + mockReadFile.mockResolvedValue('9999\n12345\n'); + mockPingDaemon.mockResolvedValue(UNREACHABLE); + mockIsProcessAlive.mockReturnValue(true); + mockCreateWallet.mockResolvedValue(createMockWallet()); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('A daemon is already running but its socket'), + ); + expect(process.exitCode).toBe(1); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('clears a corrupt PID file along with the socket so wx write can succeed', async () => { + // Pre-flight readPidFile returns undefined for a file that exists but + // doesn't parse as an integer (e.g. truncated/torn write from a crash). + // Without the rm pidPath in claimDaemonSlot, the wx write would fail + // with EEXIST and the daemon couldn't start. + mockReadFile.mockResolvedValueOnce('garbage-not-a-number\n'); + mockPingDaemon.mockResolvedValue(ABSENT); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.sock', { force: true }); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/daemon.pid', + expect.any(String), + { flag: 'wx' }, + ); + expect(process.exitCode).toBeUndefined(); + }); + it('does not remove the PID file during cleanup if its contents no longer match', async () => { const result = createMockWallet(); mockCreateWallet.mockResolvedValue(result); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); // Pre-flight finds no PID file (ENOENT). Cleanup readFile returns - // unrelated contents (a different daemon's PID file) — must not rm. + // unrelated contents (a different daemon's PID file) — must not rm + // the sibling's file during cleanup. mockReadFile .mockRejectedValueOnce(enoent()) .mockResolvedValueOnce('99999\n9999999\n'); await importDaemonEntry(); - expect(mockRm).not.toHaveBeenCalledWith('/tmp/daemon.pid', { - force: true, - }); + // Pre-flight unconditionally rms pidPath once; cleanup must NOT add + // a second rm because removeOwnedPidFile saw mismatched contents. + const pidRmCalls = mockRm.mock.calls.filter( + ([path]) => path === '/tmp/daemon.pid', + ); + expect(pidRmCalls).toHaveLength(1); expect(process.exitCode).toBe(1); }); @@ -560,9 +643,11 @@ describe('daemon-entry', () => { const lastWrite = mockWriteFile.mock.calls.at(-1)?.[1]; return typeof lastWrite === 'string' ? lastWrite : ''; }); - // Pre-flight rm (claimDaemonSlot) should succeed; shutdown-time rm should - // reject so we can verify the failure is logged rather than thrown. + // claimDaemonSlot calls rm on both pidPath and socketPath up front; let + // those succeed, and reject only the shutdown-time rms so we can verify + // the failure is logged rather than thrown. mockRm + .mockResolvedValueOnce(undefined) .mockResolvedValueOnce(undefined) .mockRejectedValue(new Error('rm failed')); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 10ddbf16992..1d0033d391e 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -7,7 +7,7 @@ import { getDaemonPaths } from './paths'; import { startRpcSocketServer } from './rpc-socket-server'; import type { RpcSocketServerHandle } from './rpc-socket-server'; import type { DaemonStatusInfo, RpcHandlerMap } from './types'; -import { isErrorWithCode } from './utils'; +import { isErrorWithCode, isProcessAlive, readPidFile } from './utils'; import { createWallet } from './wallet-factory'; const startTime = Date.now(); @@ -189,60 +189,44 @@ async function claimDaemonSlot( socketPath: string, log: (message: string) => void, ): Promise { - const existingPid = await readPidFromFile(pidPath); - if (existingPid === undefined) { - // No PID file. Still possible the socket file exists from a crashed run; - // ping it to confirm before removing. - const ping = await pingDaemon(socketPath); - if (ping.status === 'responsive') { - throw new Error( - `A daemon is already running on ${socketPath} (no PID file present)`, - ); - } - if (ping.status === 'unreachable') { - log(`Removing stale socket at ${socketPath} (${ping.error.message})`); - } - await rm(socketPath, { force: true }); - return; - } - + const existingPid = await readPidFile(pidPath); const ping = await pingDaemon(socketPath); + if (ping.status === 'responsive') { + const pidPart = + existingPid === undefined + ? '(no PID file present)' + : `(pid ${existingPid})`; + throw new Error(`A daemon is already running on ${socketPath} ${pidPart}`); + } + + if ( + ping.status === 'unreachable' && + existingPid !== undefined && + isProcessAlive(existingPid) + ) { + // Symmetric with `ensureDaemon`: do not silently take over a wedged + // sibling daemon. The user should `mm daemon stop` (or `purge`) first. throw new Error( - `A daemon is already running (pid ${existingPid}, socket ${socketPath})`, + `A daemon is already running but its socket at ${socketPath} is unresponsive ` + + `(pid ${existingPid}, ${ping.error.message}). ` + + `Run \`mm daemon stop\` (or \`mm daemon purge\`) before starting a new daemon.`, ); } - log(`Removing stale daemon state (recorded pid ${existingPid}).`); + if (ping.status === 'unreachable') { + log(`Removing stale socket at ${socketPath} (${ping.error.message}).`); + } + // Always clear both files before claiming the slot. The PID file may be + // corrupt (truncated, partial write from a crashed run); without this, the + // exclusive `wx` write below would fail with EEXIST and the daemon could + // not start until a human manually deleted the file. await Promise.all([ rm(pidPath, { force: true }), rm(socketPath, { force: true }), ]); } -/** - * Read the PID number from a PID file. Returns undefined when the file is - * missing or malformed. Reads only the first line so files written with - * `${pid}\n${startTime}\n` format are parsed correctly. - * - * @param pidPath - Path to the PID file. - * @returns The PID, or undefined if missing or unparseable. - */ -async function readPidFromFile(pidPath: string): Promise { - let contents: string; - try { - contents = await readFile(pidPath, 'utf-8'); - } catch (error: unknown) { - if (isErrorWithCode(error, 'ENOENT')) { - return undefined; - } - throw error; - } - // String.prototype.split always returns at least one element, so [0] is safe. - const pid = Number(contents.split('\n')[0].trim()); - return Number.isInteger(pid) && pid > 0 ? pid : undefined; -} - /** * Remove the PID file only if it still contains our exact contents. Guards * against a racing daemon's PID file being removed by this daemon during diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index b398aac0d3b..f4e47ff7143 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -28,9 +28,9 @@ export type EnsureDaemonResult = { * `'already-running'` (caller decides how to surface that). Otherwise spawn * one as a detached process and wait until the socket becomes responsive. * - * Refuses to spawn when the socket exists but is unreachable (wedged/foreign - * daemon) — taking over would orphan the existing process and corrupt its - * PID file. + * Refuses to spawn when pinging the existing socket fails with anything other + * than `ENOENT` (wedged or foreign daemon) — taking over could orphan the + * existing process and corrupt its PID file. * * @param config - Spawn configuration. * @returns The state of the daemon and the socket path it's listening on. diff --git a/packages/wallet-cli/src/daemon/stop-daemon.ts b/packages/wallet-cli/src/daemon/stop-daemon.ts index baba8cf04dd..d9a1d46ba94 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.ts @@ -7,11 +7,11 @@ import { isProcessAlive, readPidFile, sendSignal, waitFor } from './utils'; * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if * the socket is unresponsive, and escalates to SIGKILL if SIGTERM is ignored. * - * Signals are only sent if we have positive evidence that this PID belongs - * to our daemon — namely, the socket file at `socketPath` existed when we - * pinged (`responsive` or `unreachable`). When the socket is `absent` we - * decline to signal the recorded PID, because long-running workstations can - * recycle PIDs to unrelated processes; we just clean up the stale PID file. + * Signals are only sent if pinging the socket yielded `responsive` or + * `unreachable` (i.e. we did not see `ENOENT`). When the ping is `absent` + * we decline to signal the recorded PID, because long-running workstations + * can recycle PIDs to unrelated processes; we just clean up the stale PID + * file. * * @param socketPath - The daemon socket path. * @param pidPath - The daemon PID file path. diff --git a/packages/wallet-cli/src/daemon/utils.test.ts b/packages/wallet-cli/src/daemon/utils.test.ts index db8b7cc4d9b..08efdc4f690 100644 --- a/packages/wallet-cli/src/daemon/utils.test.ts +++ b/packages/wallet-cli/src/daemon/utils.test.ts @@ -35,11 +35,18 @@ describe('isErrorWithCode', () => { }); describe('readPidFile', () => { - it('returns the PID number from a valid file', async () => { + it('returns the PID number from a single-line file', async () => { mockReadFile.mockResolvedValue('12345'); expect(await readPidFile('/tmp/test.pid')).toBe(12345); }); + it('returns the PID from the first line when the file contains daemon metadata', async () => { + // The daemon writes `${pid}\n${startTime}\n` so it can verify ownership + // on cleanup; only the first line is the PID. + mockReadFile.mockResolvedValue('12345\n1715553908123\n'); + expect(await readPidFile('/tmp/test.pid')).toBe(12345); + }); + it('returns undefined for ENOENT', async () => { mockReadFile.mockRejectedValue( Object.assign(new Error('not found'), { code: 'ENOENT' }), @@ -62,6 +69,11 @@ describe('readPidFile', () => { expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); }); + it('returns undefined for an empty file', async () => { + mockReadFile.mockResolvedValue(''); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + it('rethrows non-ENOENT errors', async () => { mockReadFile.mockRejectedValue( Object.assign(new Error('permission denied'), { code: 'EACCES' }), diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts index f26e20d989e..d7976b0ac7a 100644 --- a/packages/wallet-cli/src/daemon/utils.ts +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -15,23 +15,29 @@ export function isErrorWithCode(error: unknown, code: string): boolean { } /** - * Read a PID from a file. + * Read a PID from a file. The file may contain just the PID, or the PID on + * the first line followed by additional metadata (e.g. start time written by + * the daemon). * * @param pidPath - The PID file path. - * @returns The PID, or undefined if the file is missing or invalid. + * @returns The PID, or undefined if the file is missing or its first line is + * not a positive integer. */ export async function readPidFile( pidPath: string, ): Promise { + let contents: string; try { - const pid = Number(await readFile(pidPath, 'utf-8')); - return pid > 0 && !Number.isNaN(pid) ? pid : undefined; + contents = await readFile(pidPath, 'utf-8'); } catch (error: unknown) { if (isErrorWithCode(error, 'ENOENT')) { return undefined; } throw error; } + // String.prototype.split always returns at least one element, so [0] is safe. + const pid = Number(contents.split('\n')[0].trim()); + return Number.isInteger(pid) && pid > 0 ? pid : undefined; } /** diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 8a8dfb7d7d7..810cd12820f 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -27,9 +27,9 @@ export type CreateWalletResult = { * * If the store does not yet contain a keyring vault (first-run), the supplied * secret recovery phrase is imported. On subsequent runs, the persisted vault - * is reused and the SRP is unused; the wallet still starts locked and the - * caller is responsible for unlocking it (`KeyringController:submitPassword`) - * before any keyring-bound operation. + * is reused and both `password` and `srp` are unused by this function; the + * wallet still starts locked and the caller is responsible for unlocking it + * via `KeyringController:submitPassword` before any keyring-bound operation. * * On any failure after the wallet is constructed, the wallet is destroyed * before the store is closed so persistence handlers unsubscribe cleanly. On a diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 84da8715662..20770a57ec2 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Export `importSecretRecoveryPhrase` from the package root so external callers (such as `@metamask/wallet-cli`) can bootstrap a `Wallet` from a BIP-39 mnemonic without reaching into the `keyring-controller` directly ([#8446](https://github.com/MetaMask/core/pull/8446)). + ### Removed - **BREAKING:** Drop the `better-sqlite3`-backed persistence layer and `./persistence` subpath export ([#8682](https://github.com/MetaMask/core/issues/8682)) From d82199f3c5abc79e92d547438be43f4868db8ea0 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 12 May 2026 17:26:48 +0200 Subject: [PATCH 29/32] fix(wallet-cli): Apply remaining review follow-ups Round 3 of review-driven fixes. Closes operational/UX gaps surfaced by the branch-wide review of `6eae8aa17` and tightens the CLI surface. Daemon IPC - `PingResult` adds a `reason: 'refused' | 'timeout' | 'permission' | 'protocol' | 'other'` discriminator so callers can act per failure mode instead of string-sniffing messages. `daemon start` reports a foreign-user daemon distinctly. The unreachable error is normalised to an `Error` instance at the producer so consumers cannot crash on string/object throws. - `startRpcSocketServer` validates incoming JSON via `@metamask/utils.isJsonRpcRequest` instead of casting `JSON.parse`'s output. The per-connection 30 s idle timer is now `unref`'d and cleared on socket close/error so an aborted probe cannot stall shutdown. - Server-side dispatch failures and unexpected socket errors flow through an optional `log` callback. The persistence layer's `subscribeToChanges` accepts the same callback. `daemon-entry.ts` wires both to the daemon log, so failures that previously vanished into `stdio: 'ignore'` now land in `daemon.log`. CLI UX - `daemon stop` reports `Daemon is not running` when there is nothing to stop, instead of returning silently. - `daemon purge` uses a static import for `@inquirer/confirm` so the prompt can be mocked in tests; whitelist deletion now extends to the SQLite `-wal` / `-shm` sidecars. Tests - Un-exclude `commands/` from coverage; add command-level tests for `start`, `stop`, `status`, `purge`, `call` covering the user-visible branches (responsive/absent/unreachable, PID-mismatch warning, refuse vs. proceed in purge, ENOENT/ECONNREFUSED friendly hints, JSON-array validation, TTY vs. piped output, etc.). A shared `runCommand` harness in `src/test/` invokes commands without going through `Config.load`. - New `src/daemon/socket-integration.test.ts` exercises a real Unix socket round trip: success response, error response, methodNotFound, shutdown RPC, concurrent in-flight requests, and pipelined-request rejection. Every other test mocks one side of this boundary; this guards the seams. - `isErrorWithCode` is now duck-typed instead of `instanceof Error` so errno values that crossed a Node-built-in realm boundary (under jest's `--experimental-vm-modules`) still classify correctly. Docs - `packages/wallet-cli/CHANGELOG.md` now lists the package's initial user-facing surface (commands + SQLite persistence) without internal hardening detail. 239 wallet-cli tests pass at 100% line/branch/function coverage; 9 wallet tests pass. Lint, constraints, both changelogs validate. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/wallet-cli/CHANGELOG.md | 9 +- packages/wallet-cli/jest.config.js | 6 +- .../src/commands/daemon/call.test.ts | 153 ++++++++++++ .../src/commands/daemon/purge.test.ts | 118 +++++++++ .../wallet-cli/src/commands/daemon/purge.ts | 2 +- .../src/commands/daemon/start.test.ts | 41 ++++ .../src/commands/daemon/status.test.ts | 118 +++++++++ .../wallet-cli/src/commands/daemon/status.ts | 3 +- .../src/commands/daemon/stop.test.ts | 71 ++++++ .../wallet-cli/src/commands/daemon/stop.ts | 11 + .../src/daemon/daemon-client.test.ts | 83 ++++++- .../wallet-cli/src/daemon/daemon-client.ts | 81 ++++++- .../src/daemon/daemon-entry.test.ts | 2 + .../wallet-cli/src/daemon/daemon-entry.ts | 2 + .../src/daemon/daemon-spawn.test.ts | 16 ++ .../wallet-cli/src/daemon/daemon-spawn.ts | 7 + .../src/daemon/rpc-socket-server.test.ts | 28 ++- .../src/daemon/rpc-socket-server.ts | 152 +++++++++--- .../src/daemon/socket-integration.test.ts | 224 ++++++++++++++++++ .../wallet-cli/src/daemon/stop-daemon.test.ts | 1 + packages/wallet-cli/src/daemon/utils.ts | 8 +- .../src/daemon/wallet-factory.test.ts | 20 ++ .../wallet-cli/src/daemon/wallet-factory.ts | 10 +- .../src/persistence/persistence.test.ts | 44 +++- .../wallet-cli/src/persistence/persistence.ts | 15 +- packages/wallet-cli/src/test/run-command.ts | 86 +++++++ 26 files changed, 1242 insertions(+), 69 deletions(-) create mode 100644 packages/wallet-cli/src/commands/daemon/call.test.ts create mode 100644 packages/wallet-cli/src/commands/daemon/purge.test.ts create mode 100644 packages/wallet-cli/src/commands/daemon/start.test.ts create mode 100644 packages/wallet-cli/src/commands/daemon/status.test.ts create mode 100644 packages/wallet-cli/src/commands/daemon/stop.test.ts create mode 100644 packages/wallet-cli/src/daemon/socket-integration.test.ts create mode 100644 packages/wallet-cli/src/test/run-command.ts diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index 4567905df49..d28247ff22c 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -9,9 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Persist controller state to a `better-sqlite3` key-value store inside the daemon's data directory ([#8682](https://github.com/MetaMask/core/issues/8682)) - - The persistence layer (`KeyValueStore`, `loadState`, `subscribeToChanges`) moves here from `@metamask/wallet`. - - The daemon writes to `/wallet.db`, hydrates the `Wallet` from the store on startup, subscribes to the `:stateChanged` events of controllers that declare persist-flagged state for write-through persistence, and closes the store during shutdown. - - On subsequent runs the daemon reuses the persisted KeyringController vault instead of re-importing the supplied SRP. The wallet still starts locked; unlock is the caller's responsibility. +- Initial release of `@metamask/wallet-cli`, an oclif-based `mm` CLI that runs `@metamask/wallet` as a Unix-socket daemon ([#8446](https://github.com/MetaMask/core/pull/8446)). + - `mm daemon start` spawns the daemon with `--infura-project-id`, `--password`, `--srp` (or the matching env vars). + - `mm daemon call []` dispatches any messenger action over JSON-RPC. + - `mm daemon stop`, `mm daemon status`, `mm daemon purge` manage daemon lifecycle and state. +- Persist daemon state to a SQLite database at `/wallet.db`; subsequent `daemon start` runs reuse the persisted KeyringController vault instead of re-importing the SRP ([#8682](https://github.com/MetaMask/core/issues/8682)). [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js index e863064fbc6..aacc39c51a7 100644 --- a/packages/wallet-cli/jest.config.js +++ b/packages/wallet-cli/jest.config.js @@ -14,8 +14,10 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, - // TODO: Add tests for commands - coveragePathIgnorePatterns: ['.*/commands/.*'], + // The test harness in `src/test/` is exercised by the command tests but + // not all of its error/edge branches are worth driving directly — it's + // production code's test infrastructure, not production code itself. + coveragePathIgnorePatterns: ['.*/src/test/.*'], // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { diff --git a/packages/wallet-cli/src/commands/daemon/call.test.ts b/packages/wallet-cli/src/commands/daemon/call.test.ts new file mode 100644 index 00000000000..c43ec9c91b5 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/call.test.ts @@ -0,0 +1,153 @@ +import { sendCommand } from '../../daemon/daemon-client'; +import { runCommand } from '../../test/run-command'; +import DaemonCall from './call'; + +jest.mock('../../daemon/daemon-client'); + +const mockSendCommand = jest.mocked(sendCommand); + +const ACTION = 'AccountsController:listAccounts'; + +describe('daemon call', () => { + beforeEach(() => { + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { accounts: [] }, + }); + }); + + it('dispatches the action with no params', async () => { + await runCommand(DaemonCall, [ACTION]); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'call', + params: [ACTION], + }), + ); + }); + + it('parses a JSON-array params argument and appends to the params list', async () => { + await runCommand(DaemonCall, [ACTION, '["arg1", 42]']); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'call', + params: [ACTION, 'arg1', 42], + }), + ); + }); + + it('errors when params is not valid JSON', async () => { + const { error } = await runCommand(DaemonCall, [ACTION, 'not json']); + + expect(error?.message).toContain('valid JSON'); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); + + it('errors when params is JSON but not an array', async () => { + const { error } = await runCommand(DaemonCall, [ACTION, '{"foo":1}']); + + expect(error?.message).toContain('JSON array'); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); + + it('passes the timeout flag through to sendCommand', async () => { + await runCommand(DaemonCall, [ACTION, '--timeout', '5000']); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 5000 }), + ); + }); + + it('returns a friendly hint when the daemon is not running (ENOENT)', async () => { + mockSendCommand.mockRejectedValue( + Object.assign(new Error('no such file'), { code: 'ENOENT' }), + ); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Daemon is not running'); + }); + + it('returns a friendly hint when the daemon refuses the connection', async () => { + mockSendCommand.mockRejectedValue( + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Daemon is not running'); + }); + + it('surfaces other socket errors with the raw message', async () => { + mockSendCommand.mockRejectedValue(new Error('Socket read timed out')); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Socket read timed out'); + }); + + it('handles non-Error throws from sendCommand', async () => { + mockSendCommand.mockImplementation(async () => + // Simulate a non-Error throw (the call site does not narrow to Error). + Promise.reject('string error' as unknown as Error), + ); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('string error'); + }); + + it('errors when the daemon returns a JSON-RPC failure response', async () => { + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + error: { code: -32601, message: 'Method not found' }, + }); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Method not found'); + expect(error?.message).toContain('-32601'); + }); + + it('writes pretty JSON to a TTY stdout', async () => { + const original = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + + const { stdout } = await runCommand(DaemonCall, [ACTION]); + + expect(stdout).toContain('"accounts": []'); + + Object.defineProperty(process.stdout, 'isTTY', { + value: original, + configurable: true, + }); + }); + + it('writes compact JSON to a piped (non-TTY) stdout', async () => { + const original = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + const writeSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + await runCommand(DaemonCall, [ACTION]); + + expect(writeSpy).toHaveBeenCalledWith('{"accounts":[]}\n'); + + writeSpy.mockRestore(); + Object.defineProperty(process.stdout, 'isTTY', { + value: original, + configurable: true, + }); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/purge.test.ts b/packages/wallet-cli/src/commands/daemon/purge.test.ts new file mode 100644 index 00000000000..6526e38f254 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/purge.test.ts @@ -0,0 +1,118 @@ +import confirm from '@inquirer/confirm'; +import { rm } from 'node:fs/promises'; + +import { pingDaemon } from '../../daemon/daemon-client'; +import { stopDaemon } from '../../daemon/stop-daemon'; +import { runCommand } from '../../test/run-command'; +import DaemonPurge from './purge'; + +jest.mock('node:fs/promises'); +jest.mock('../../daemon/daemon-client'); +jest.mock('../../daemon/stop-daemon'); +jest.mock('@inquirer/confirm'); + +const inquirerConfirm = jest.mocked(confirm); +const mockRm = jest.mocked(rm); +const mockPingDaemon = jest.mocked(pingDaemon); +const mockStopDaemon = jest.mocked(stopDaemon); + +describe('daemon purge', () => { + beforeEach(() => { + mockRm.mockResolvedValue(undefined); + inquirerConfirm.mockResolvedValue(true); + }); + + it('aborts without prompting nor deleting when the user declines', async () => { + inquirerConfirm.mockResolvedValue(false); + + const { stdout, error } = await runCommand(DaemonPurge); + + expect(stdout).toContain('Aborted.'); + expect(mockStopDaemon).not.toHaveBeenCalled(); + expect(mockRm).not.toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('--force skips the confirmation prompt', async () => { + mockStopDaemon.mockResolvedValue(true); + + await runCommand(DaemonPurge, ['--force']); + + expect(inquirerConfirm).not.toHaveBeenCalled(); + expect(mockStopDaemon).toHaveBeenCalled(); + }); + + it('threads its log callback into stopDaemon so daemon-side messages reach the user', async () => { + mockStopDaemon.mockImplementation(async (_socket, _pid, log) => { + log?.('Stopping daemon...'); + return true; + }); + + const { stdout } = await runCommand(DaemonPurge, ['--force']); + + expect(stdout).toContain('Stopping daemon...'); + }); + + it('refuses to delete state when the daemon is still responsive', async () => { + mockStopDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + + const { error } = await runCommand(DaemonPurge, ['--force']); + + expect(error?.message).toContain('still responsive'); + expect(mockRm).not.toHaveBeenCalled(); + }); + + it('proceeds to delete the whitelist when stopDaemon returns false but the daemon is unresponsive', async () => { + mockStopDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue({ + status: 'unreachable', + reason: 'refused', + error: new Error('refused'), + }); + + const { stdout } = await runCommand(DaemonPurge, ['--force']); + + expect(stdout).toContain('Could not confirm clean shutdown'); + expect(stdout).toContain('All daemon state deleted.'); + }); + + it('proceeds when stopDaemon returns false and the daemon is absent', async () => { + mockStopDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + + const { stdout } = await runCommand(DaemonPurge, ['--force']); + + expect(stdout).toContain('All daemon state deleted.'); + }); + + it('deletes only the whitelisted daemon files (not the entire dataDir)', async () => { + mockStopDaemon.mockResolvedValue(true); + + await runCommand(DaemonPurge, ['--force']); + + const removed = mockRm.mock.calls.map(([path]) => path); + // The whitelist is built from getDaemonPaths(dataDir).{pidPath,socketPath, + // logPath,dbPath} plus the SQLite WAL/SHM sidecars. None of them is the + // dataDir itself. + expect(removed).not.toContain('/tmp/mm-cli-test-data'); + expect(removed.some((path) => String(path).endsWith('daemon.pid'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('daemon.sock'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('daemon.log'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('wallet.db'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('wallet.db-wal'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('wallet.db-shm'))).toBe( + true, + ); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts index d1db9d7586f..9792aec6a0e 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -1,3 +1,4 @@ +import confirm from '@inquirer/confirm'; import { Command, Flags } from '@oclif/core'; import { rm } from 'node:fs/promises'; @@ -25,7 +26,6 @@ export default class DaemonPurge extends Command { const { flags } = await this.parse(DaemonPurge); if (!flags.force) { - const { default: confirm } = await import('@inquirer/confirm'); const confirmed = await confirm({ message: 'This will stop the daemon and delete all state. Continue?', default: false, diff --git a/packages/wallet-cli/src/commands/daemon/start.test.ts b/packages/wallet-cli/src/commands/daemon/start.test.ts new file mode 100644 index 00000000000..68e044010e4 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/start.test.ts @@ -0,0 +1,41 @@ +import { ensureDaemon } from '../../daemon/daemon-spawn'; +import { runCommand } from '../../test/run-command'; +import DaemonStart from './start'; + +jest.mock('../../daemon/daemon-spawn'); + +const mockEnsureDaemon = jest.mocked(ensureDaemon); + +const FLAGS = [ + '--infura-project-id', + 'key', + '--password', + 'pw', + '--srp', + 'phrase', +]; + +describe('daemon start', () => { + it('reports the socket path on a fresh start', async () => { + mockEnsureDaemon.mockResolvedValue({ + state: 'started', + socketPath: '/tmp/daemon.sock', + }); + + const { stdout } = await runCommand(DaemonStart, FLAGS); + + expect(stdout).toContain('Daemon running. Socket: /tmp/daemon.sock'); + }); + + it('warns that flags were not applied when a daemon is already running', async () => { + mockEnsureDaemon.mockResolvedValue({ + state: 'already-running', + socketPath: '/tmp/daemon.sock', + }); + + const { stdout } = await runCommand(DaemonStart, FLAGS); + + expect(stdout).toContain('Daemon already running'); + expect(stdout).toContain('not applied'); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/status.test.ts b/packages/wallet-cli/src/commands/daemon/status.test.ts new file mode 100644 index 00000000000..47f18a9cfd3 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/status.test.ts @@ -0,0 +1,118 @@ +import { pingDaemon, sendCommand } from '../../daemon/daemon-client'; +import { readPidFile } from '../../daemon/utils'; +import { runCommand } from '../../test/run-command'; +import DaemonStatus from './status'; + +jest.mock('../../daemon/daemon-client'); +jest.mock('../../daemon/utils'); + +const mockPingDaemon = jest.mocked(pingDaemon); +const mockSendCommand = jest.mocked(sendCommand); +const mockReadPidFile = jest.mocked(readPidFile); + +describe('daemon status', () => { + beforeEach(() => { + mockReadPidFile.mockResolvedValue(12345); + }); + + it('reports "not running" when the socket is absent', async () => { + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('Daemon is not running.'); + }); + + it('reports the unreachable reason and recorded PID', async () => { + mockPingDaemon.mockResolvedValue({ + status: 'unreachable', + reason: 'refused', + error: new Error('ECONNREFUSED'), + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('is unresponsive'); + expect(stdout).toContain('recorded PID: 12345'); + expect(stdout).toContain('[refused]'); + expect(stdout).toContain('ECONNREFUSED'); + }); + + it('omits the PID suffix when no PID file is present', async () => { + mockReadPidFile.mockResolvedValue(undefined); + mockPingDaemon.mockResolvedValue({ + status: 'unreachable', + reason: 'timeout', + error: new Error('timeout'), + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('is unresponsive'); + expect(stdout).not.toContain('recorded PID'); + }); + + it('reports a status-request failure distinctly from an absent or unreachable daemon', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockRejectedValue(new Error('timed out')); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('responsive but status request failed'); + expect(stdout).toContain('timed out'); + }); + + it('reports a JSON-RPC error response from getStatus', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + error: { code: -32000, message: 'boom' }, + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('returned an error: boom'); + }); + + it('reports PID and uptime on success', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { pid: 12345, uptime: 42 }, + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('PID: 12345, Uptime: 42s'); + }); + + it('warns when the local PID file disagrees with the running daemon', async () => { + mockReadPidFile.mockResolvedValue(99999); + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { pid: 12345, uptime: 42 }, + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain( + 'Warning: PID file records 99999 but the running daemon reports 12345', + ); + }); + + it('handles non-Error throws from sendCommand', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockImplementation(async () => + // Simulate a non-Error throw (the call site does not narrow to Error). + Promise.reject('string error' as unknown as Error), + ); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('status request failed: string error'); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/status.ts b/packages/wallet-cli/src/commands/daemon/status.ts index 4bd3a23f4ef..69611aad504 100644 --- a/packages/wallet-cli/src/commands/daemon/status.ts +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -25,7 +25,8 @@ export default class DaemonStatus extends Command { if (ping.status === 'unreachable') { const pidPart = pid === undefined ? '' : ` (recorded PID: ${pid})`; this.log( - `Daemon socket exists at ${socketPath} but is unresponsive${pidPart}: ${ping.error.message}`, + `Daemon socket exists at ${socketPath} but is unresponsive${pidPart} ` + + `[${ping.reason}]: ${ping.error.message}`, ); return; } diff --git a/packages/wallet-cli/src/commands/daemon/stop.test.ts b/packages/wallet-cli/src/commands/daemon/stop.test.ts new file mode 100644 index 00000000000..b30d7152fa6 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/stop.test.ts @@ -0,0 +1,71 @@ +import { pingDaemon } from '../../daemon/daemon-client'; +import { stopDaemon } from '../../daemon/stop-daemon'; +import { readPidFile } from '../../daemon/utils'; +import { runCommand } from '../../test/run-command'; +import DaemonStop from './stop'; + +jest.mock('../../daemon/daemon-client'); +jest.mock('../../daemon/stop-daemon'); +jest.mock('../../daemon/utils'); + +const mockPingDaemon = jest.mocked(pingDaemon); +const mockStopDaemon = jest.mocked(stopDaemon); +const mockReadPidFile = jest.mocked(readPidFile); + +describe('daemon stop', () => { + it('reports "Daemon is not running" when no socket and no PID file exist', async () => { + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + mockReadPidFile.mockResolvedValue(undefined); + + const { stdout, error } = await runCommand(DaemonStop); + + expect(stdout).toContain('Daemon is not running.'); + expect(mockStopDaemon).not.toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('invokes stopDaemon when a PID file exists even if the socket is absent', async () => { + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockResolvedValue(true); + + const { error } = await runCommand(DaemonStop); + + expect(mockStopDaemon).toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('invokes stopDaemon when the socket is responsive', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockResolvedValue(true); + + const { error } = await runCommand(DaemonStop); + + expect(mockStopDaemon).toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('threads its log callback into stopDaemon so daemon-side messages reach the user', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockImplementation(async (_socket, _pid, log) => { + log?.('Stopping daemon...'); + return true; + }); + + const { stdout } = await runCommand(DaemonStop); + + expect(stdout).toContain('Stopping daemon...'); + }); + + it('errors when stopDaemon returns false', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockResolvedValue(false); + + const { error } = await runCommand(DaemonStop); + + expect(error?.message).toContain('did not stop within timeout'); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/stop.ts b/packages/wallet-cli/src/commands/daemon/stop.ts index 5df3eb64b1a..62a85588fa5 100644 --- a/packages/wallet-cli/src/commands/daemon/stop.ts +++ b/packages/wallet-cli/src/commands/daemon/stop.ts @@ -1,7 +1,9 @@ import { Command } from '@oclif/core'; +import { pingDaemon } from '../../daemon/daemon-client'; import { getDaemonPaths } from '../../daemon/paths'; import { stopDaemon } from '../../daemon/stop-daemon'; +import { readPidFile } from '../../daemon/utils'; export default class DaemonStop extends Command { static override description = 'Stop the wallet daemon'; @@ -11,6 +13,15 @@ export default class DaemonStop extends Command { public async run(): Promise { const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); + // Distinguish "no daemon was running" from "successful stop" so the user + // gets feedback either way. + const ping = await pingDaemon(socketPath); + const pid = await readPidFile(pidPath); + if (ping.status === 'absent' && pid === undefined) { + this.log('Daemon is not running.'); + return; + } + const stopped = await stopDaemon(socketPath, pidPath, (message) => this.log(message), ); diff --git a/packages/wallet-cli/src/daemon/daemon-client.test.ts b/packages/wallet-cli/src/daemon/daemon-client.test.ts index 89133e8dab0..783413213b2 100644 --- a/packages/wallet-cli/src/daemon/daemon-client.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-client.test.ts @@ -246,7 +246,7 @@ describe('pingDaemon', () => { }); }); - it('returns unreachable when the socket exists but is wedged', async () => { + it('returns unreachable with reason=refused when the socket refuses connection', async () => { // ECONNREFUSED is retried once; both attempts will reject with the same // mock implementation. mockConnectionError('ECONNREFUSED'); @@ -254,7 +254,88 @@ describe('pingDaemon', () => { const result = await pingDaemon('/tmp/test.sock'); expect(result).toStrictEqual({ status: 'unreachable', + reason: 'refused', error: expect.any(Error), }); }); + + it('returns unreachable with reason=permission on EACCES', async () => { + mockConnectionError('EACCES'); + + const result = await pingDaemon('/tmp/test.sock'); + expect(result).toMatchObject({ + status: 'unreachable', + reason: 'permission', + }); + }); + + it('returns unreachable with reason=timeout when the socket read times out', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockRejectedValue(new Error('Socket read timed out')); + + const result = await pingDaemon('/tmp/test.sock'); + expect(result).toMatchObject({ + status: 'unreachable', + reason: 'timeout', + }); + }); + + it('returns unreachable with reason=protocol on a JSON-RPC id mismatch', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue( + JSON.stringify({ + jsonrpc: '2.0', + id: 'unrelated-id', + result: { status: 'ok' }, + }), + ); + + const result = await pingDaemon('/tmp/test.sock'); + expect(result).toMatchObject({ + status: 'unreachable', + reason: 'protocol', + }); + }); + + it('returns unreachable with reason=protocol on a JSON parse error', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue('not json'); + + const result = await pingDaemon('/tmp/test.sock'); + expect(result).toMatchObject({ + status: 'unreachable', + reason: 'protocol', + }); + }); + + it('returns unreachable with reason=other for unclassified errors', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockRejectedValue(new Error('something weird')); + + const result = await pingDaemon('/tmp/test.sock'); + expect(result).toMatchObject({ + status: 'unreachable', + reason: 'other', + }); + }); + + it('normalizes non-Error throws into an Error instance', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + // Simulate a non-Error throw; the producer must normalize it. + mockReadLine.mockImplementation(async () => + Promise.reject('string-throw' as unknown as Error), + ); + + const result = await pingDaemon('/tmp/test.sock'); + expect(result).toStrictEqual({ + status: 'unreachable', + reason: 'other', + error: expect.objectContaining({ message: 'string-throw' }), + }); + }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-client.ts b/packages/wallet-cli/src/daemon/daemon-client.ts index b96c7b806c9..932e8aa8f48 100644 --- a/packages/wallet-cli/src/daemon/daemon-client.ts +++ b/packages/wallet-cli/src/daemon/daemon-client.ts @@ -97,22 +97,85 @@ export async function sendCommand({ } } +/** + * Why an unreachable daemon cannot be queried. + * + * - `'refused'`: connection refused after retry (`ECONNREFUSED` / `ECONNRESET`). + * Typical of a daemon that has crashed or is mid-restart. + * - `'timeout'`: the daemon accepted the connection but did not respond within + * the read timeout — most likely wedged on a long-running operation. + * - `'permission'`: the socket exists but cannot be opened (`EACCES` / `EPERM`). + * The daemon almost certainly belongs to another user. + * - `'protocol'`: the daemon responded but the response did not parse as a + * valid JSON-RPC response, or the response id did not match. + * - `'other'`: anything else. + */ +export type PingUnreachableReason = + | 'refused' + | 'timeout' + | 'permission' + | 'protocol' + | 'other'; + /** * Outcome of a daemon health check. * * - `'responsive'`: the daemon answered a `getStatus` RPC. * - `'absent'`: the socket connect attempt failed with `ENOENT`, i.e. no * socket file exists at the path. No daemon present. - * - `'unreachable'`: any other non-success outcome (refused after retry, - * timeout, permission denied, parse error, id mismatch, etc.). The daemon - * may still be alive but is not responding. Callers should not silently - * take over the slot or assume the daemon is dead. User-initiated stop / - * purge flows may still escalate to signals against the recorded PID. + * - `'unreachable'`: any other non-success outcome. The daemon may still be + * alive but is not responding. Callers should not silently take over the + * slot or assume the daemon is dead. The `reason` field categorises the + * failure so callers can distinguish a wedged sibling daemon from a + * foreign-user daemon from a transient crash. User-initiated stop / purge + * flows may still escalate to signals against the recorded PID. */ export type PingResult = | { status: 'responsive' } | { status: 'absent' } - | { status: 'unreachable'; error: Error }; + | { status: 'unreachable'; reason: PingUnreachableReason; error: Error }; + +/** + * Normalize an unknown throw into a real Error instance so that downstream + * consumers (which read `.message`) cannot crash on string/object throws. + * + * @param error - The caught value. + * @returns An Error mirroring the caught value. + */ +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +/** + * Categorise an unreachable error by Node errno / message shape so callers can + * make decisions per failure mode rather than parsing message strings. + * + * @param error - The caught value. + * @returns A {@link PingUnreachableReason} label. + */ +function classifyUnreachable(error: unknown): PingUnreachableReason { + if ( + isErrorWithCode(error, 'ECONNREFUSED') || + isErrorWithCode(error, 'ECONNRESET') + ) { + return 'refused'; + } + if (isErrorWithCode(error, 'EACCES') || isErrorWithCode(error, 'EPERM')) { + return 'permission'; + } + if (error instanceof Error && error.message === 'Socket read timed out') { + return 'timeout'; + } + if ( + error instanceof Error && + (error.message.includes('JSON-RPC response id') || + /Expected .* JSON-RPC/u.test(error.message) || + error.name === 'SyntaxError') + ) { + return 'protocol'; + } + return 'other'; +} /** * Check whether the daemon is running by sending a lightweight `getStatus` @@ -131,6 +194,10 @@ export async function pingDaemon(socketPath: string): Promise { if (isErrorWithCode(error, 'ENOENT')) { return { status: 'absent' }; } - return { status: 'unreachable', error: error as Error }; + return { + status: 'unreachable', + reason: classifyUnreachable(error), + error: toError(error), + }; } } diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 9da228b2566..e48f53ec900 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -39,6 +39,7 @@ const ABSENT = { status: 'absent' as const }; const RESPONSIVE = { status: 'responsive' as const }; const UNREACHABLE = { status: 'unreachable' as const, + reason: 'refused' as const, error: new Error('wedged'), }; @@ -188,6 +189,7 @@ describe('daemon-entry', () => { infuraProjectId: 'key', password: 'pass', srp: 'test test test test test test test test test test test ball', + log: expect.any(Function), }); expect(mockWriteFile).toHaveBeenCalledWith( '/tmp/daemon.pid', diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 1d0033d391e..408940dac59 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -66,6 +66,7 @@ async function main(): Promise { infuraProjectId, password, srp, + log, }); const handlers: RpcHandlerMap = { @@ -99,6 +100,7 @@ async function main(): Promise { socketPath, handlers, onShutdown: async () => shutdown('RPC shutdown'), + log, }); } catch (error) { try { diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts index fdff2d289f9..4828f4d33b1 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -28,9 +28,16 @@ const ABSENT = { status: 'absent' as const }; const RESPONSIVE = { status: 'responsive' as const }; const UNREACHABLE = { status: 'unreachable' as const, + reason: 'refused' as const, error: new Error('wedged'), }; +const UNREACHABLE_PERMISSION = { + status: 'unreachable' as const, + reason: 'permission' as const, + error: new Error('EACCES'), +}; + /** * Build a minimal mock for the `ChildProcess` returned by `spawn`. The `on` * handler captures `'exit'`/`'error'` listeners so tests can fire them. @@ -99,6 +106,15 @@ describe('ensureDaemon', () => { expect(mockSpawn).not.toHaveBeenCalled(); }); + it('reports a foreign-user daemon distinctly when the ping reason is permission', async () => { + mockPingDaemon.mockResolvedValue(UNREACHABLE_PERMISSION); + + await expect(ensureDaemon(CONFIG)).rejects.toThrow( + /owned by another user/u, + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + it('spawns daemon as detached child with correct env vars', async () => { mockPingDaemon .mockResolvedValueOnce(ABSENT) diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index f4e47ff7143..1f6a2414bc1 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -45,6 +45,13 @@ export async function ensureDaemon( return { state: 'already-running', socketPath }; } if (initialPing.status === 'unreachable') { + if (initialPing.reason === 'permission') { + throw new Error( + `Refusing to start: the socket at ${socketPath} is owned by another user. ` + + `Choose a different data directory (MM_DAEMON_DATA_DIR) or remove the socket manually. ` + + `(${initialPing.error.message})`, + ); + } throw new Error( `Refusing to start: a daemon socket already exists at ${socketPath} but is unresponsive. ` + `Run \`mm daemon stop\` (or \`mm daemon purge\`) before starting a new daemon. ` + diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts index 29bc128640b..210d964fdcd 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -233,11 +233,37 @@ describe('startRpcSocketServer', () => { expect(getResponse(socket).error).toStrictEqual( expect.objectContaining({ code: -32600, - message: 'Invalid request: missing method', + message: 'Invalid JSON-RPC request', }), ); }); + it('returns -32600 for a request whose id is an object', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { + jsonrpc: '2.0', + id: { nested: 'bad' }, + method: 'getStatus', + }); + + await flushPromises(); + + const response = getResponse(socket); + // Per JSON-RPC 2.0, id must be string/number/null. We cannot echo the + // object back, so respond with id: null. + expect(response.id).toBeNull(); + expect(response.error).toStrictEqual( + expect.objectContaining({ code: -32600 }), + ); + }); + it('returns -32601 for unknown method', async () => { const { simulateConnection } = createMockServer(); await startRpcSocketServer({ diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 5e00290e745..76d45aefab9 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -1,6 +1,10 @@ import { rpcErrors } from '@metamask/rpc-errors'; -import type { JsonRpcResponse } from '@metamask/utils'; -import { hasProperty } from '@metamask/utils'; +import type { + JsonRpcId, + JsonRpcParams, + JsonRpcResponse, +} from '@metamask/utils'; +import { hasProperty, isJsonRpcRequest } from '@metamask/utils'; import { unlink } from 'node:fs/promises'; import { createServer } from 'node:net'; import type { Server } from 'node:net'; @@ -17,6 +21,25 @@ export type RpcSocketServerHandle = { close: () => Promise; }; +/** + * Options for {@link startRpcSocketServer}. + */ +export type StartRpcSocketServerOptions = { + /** The Unix socket path to listen on. */ + socketPath: string; + /** Map of RPC method names to handler functions. */ + handlers: RpcHandlerMap; + /** Callback invoked when a `shutdown` RPC is received. */ + onShutdown?: (() => Promise) | undefined; + /** + * Optional logger for server-side diagnostics (unexpected socket errors, + * unhandled handler rejections, `onShutdown` callback failures). Without + * this, failures fall back to `process.stderr.write`, which is discarded + * when the daemon is spawned with `stdio: 'ignore'`. + */ + log?: ((message: string) => void) | undefined; +}; + /** * Start a Unix socket server that processes JSON-RPC requests. * @@ -24,30 +47,41 @@ export type RpcSocketServerHandle = { * via the provided handler map, writes a JSON-RPC response, and closes. * * The special `shutdown` method is intercepted before handler dispatch and - * triggers the provided {@link onShutdown} callback after responding. + * triggers the provided {@link StartRpcSocketServerOptions.onShutdown} callback + * after responding. * * @param options - Server options. * @param options.socketPath - The Unix socket path to listen on. * @param options.handlers - Map of RPC method names to handler functions. - * @param options.onShutdown - Callback invoked when a `shutdown` RPC is received. + * @param options.onShutdown - Optional callback invoked when a `shutdown` RPC is received. + * @param options.log - Optional logger for server-side diagnostics. * @returns A handle with a `close()` function for cleanup. */ export async function startRpcSocketServer({ socketPath, handlers, onShutdown, -}: { - socketPath: string; - handlers: RpcHandlerMap; - onShutdown?: (() => Promise) | undefined; -}): Promise { + log, +}: StartRpcSocketServerOptions): Promise { + const logFn = log ?? defaultLog; + const server = createServer((socket) => { let buffer = ''; - // Destroy connections that never send a complete request line. + // Destroy connections that never send a complete request line. `unref` so + // the timer alone cannot keep the event loop alive at shutdown. const timer = setTimeout(() => { socket.destroy(); }, CONNECTION_TIMEOUT_MS); + timer.unref(); + + /** + * Clear the idle-connection timer. Called from data, close, and error + * paths so the timer never outlives the connection itself. + */ + const clearIdleTimer = (): void => { + clearTimeout(timer); + }; const onData = (data: Buffer): void => { buffer += data.toString(); @@ -56,7 +90,7 @@ export async function startRpcSocketServer({ return; } - clearTimeout(timer); + clearIdleTimer(); // One request per connection. socket.removeListener('data', onData); @@ -79,12 +113,13 @@ export async function startRpcSocketServer({ return; } - handleRequest(handlers, line, onShutdown) + handleRequest(handlers, line, onShutdown, logFn) .then((response) => { socket.end(`${JSON.stringify(response)}\n`); return undefined; }) - .catch(() => { + .catch((dispatchError: unknown) => { + logFn(`Unhandled RPC dispatch error: ${String(dispatchError)}`); socket.end( `${JSON.stringify({ jsonrpc: '2.0', @@ -96,13 +131,14 @@ export async function startRpcSocketServer({ }); }; socket.on('data', onData); - + socket.once('close', clearIdleTimer); socket.on('error', (socketError: NodeJS.ErrnoException) => { + clearIdleTimer(); const { code } = socketError; if (code === 'EPIPE' || code === 'ECONNRESET') { return; // Expected during probe/disconnect. } - process.stderr.write(`Unexpected socket error: ${String(socketError)}\n`); + logFn(`Unexpected socket error: ${String(socketError)}`); }); }); @@ -123,25 +159,34 @@ export async function startRpcSocketServer({ }; } +/** + * Default fallback logger: writes to stderr. Daemons spawned with + * `stdio: 'ignore'` should always pass an explicit `log`. + * + * @param message - The message to log. + */ +function defaultLog(message: string): void { + process.stderr.write(`${message}\n`); +} + /** * Handle a single JSON-RPC request line, intercepting the `shutdown` method. * * @param handlers - The RPC handler map. * @param line - The raw JSON line from the socket. * @param onShutdown - Optional shutdown callback. + * @param log - Logger for diagnostic messages. * @returns A JSON-RPC response object. */ async function handleRequest( handlers: RpcHandlerMap, line: string, - onShutdown?: () => Promise, + onShutdown: (() => Promise) | undefined, + log: (message: string) => void, ): Promise { - type JsonRpcId = string | number | null; - let id: JsonRpcId = null; - let request: { id?: unknown; method?: string; params?: unknown }; - + let parsed: unknown; try { - request = JSON.parse(line) as typeof request; + parsed = JSON.parse(line); } catch { return { jsonrpc: '2.0', @@ -150,29 +195,32 @@ async function handleRequest( }; } - id = (request.id ?? null) as JsonRpcId; - - try { - const { method } = request; + if (!isJsonRpcRequest(parsed)) { + const id: JsonRpcId = + typeof parsed === 'object' && + parsed !== null && + hasProperty(parsed, 'id') && + isValidJsonRpcId(parsed.id) + ? parsed.id + : null; + return { + jsonrpc: '2.0', + id, + error: rpcErrors + .invalidRequest({ message: 'Invalid JSON-RPC request' }) + .serialize(), + }; + } - if (typeof method !== 'string') { - return { - jsonrpc: '2.0', - id, - error: rpcErrors - .invalidRequest({ message: 'Invalid request: missing method' }) - .serialize(), - }; - } + const { id, method, params } = parsed; + try { // Intercept shutdown before handler dispatch. if (method === 'shutdown') { if (onShutdown) { setTimeout(() => { onShutdown().catch((error: unknown) => { - process.stderr.write( - `onShutdown callback failed: ${String(error)}\n`, - ); + log(`onShutdown callback failed: ${String(error)}`); }); }, 0); } @@ -190,10 +238,10 @@ async function handleRequest( }; } - const params = (request.params as Parameters[0]) ?? null; - const result = await handler(params); + const result = await handler(coerceHandlerParams(params)); return { jsonrpc: '2.0', id, result: result ?? null }; } catch (error) { + log(`RPC handler "${method}" failed: ${String(error)}`); if (isRpcError(error)) { return { jsonrpc: '2.0', id, error }; } @@ -206,6 +254,32 @@ async function handleRequest( } } +/** + * Narrow `params` to the shape handlers expect. JSON-RPC 2.0 requires + * `params`, when present, to be an array or object; both are valid `Json`. + * + * @param params - The validated `params` field from a JSON-RPC request. + * @returns The same value, or `null` when absent. + */ +function coerceHandlerParams( + params: JsonRpcParams | undefined, +): JsonRpcParams | null { + return params ?? null; +} + +/** + * Per JSON-RPC 2.0, `id` must be a string, number, or null. Used when + * salvaging an `id` from a parse-success-but-not-valid-request payload. + * + * @param value - The candidate id. + * @returns True if the value is an acceptable JSON-RPC id. + */ +function isValidJsonRpcId(value: unknown): value is JsonRpcId { + return ( + value === null || typeof value === 'string' || typeof value === 'number' + ); +} + /** * Check if an error is an RPC error with a numeric code. * diff --git a/packages/wallet-cli/src/daemon/socket-integration.test.ts b/packages/wallet-cli/src/daemon/socket-integration.test.ts new file mode 100644 index 00000000000..0327ed918d4 --- /dev/null +++ b/packages/wallet-cli/src/daemon/socket-integration.test.ts @@ -0,0 +1,224 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pingDaemon, sendCommand } from './daemon-client'; +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcSocketServerHandle } from './rpc-socket-server'; + +/** + * End-to-end integration tests for the daemon's IPC layer: real + * `startRpcSocketServer` listening on a real Unix socket, real `sendCommand` + * speaking newline-delimited JSON-RPC over `net.createConnection`. Every + * other test in this package mocks one side of this boundary; these tests + * guard against bugs that only surface when both halves run together + * (framing, response-id correlation, the "one request per connection" + * invariant, real shutdown timing). + */ +describe('socket integration', () => { + const openHandles: RpcSocketServerHandle[] = []; + let socketPath: string; + + beforeEach(() => { + socketPath = join( + tmpdir(), + `mm-cli-it-${process.pid}-${Date.now()}-${Math.random()}.sock`, + ); + }); + + afterEach(async () => { + while (openHandles.length > 0) { + const handle = openHandles.pop(); + await handle?.close().catch(() => undefined); + } + }); + + /** + * Start an RPC server, register its handle for afterEach cleanup, and + * return it. Avoids the `require-atomic-updates` shape lint complains + * about when assigning to a let-bound variable across awaits. + * + * @param options - Options forwarded to `startRpcSocketServer`. + * @returns The started server's handle. + */ + async function startServer( + options: Parameters[0], + ): Promise { + const handle = await startRpcSocketServer(options); + openHandles.push(handle); + return handle; + } + + it('round-trips a JSON-RPC request between sendCommand and startRpcSocketServer', async () => { + await startServer({ + socketPath, + handlers: { + getStatus: async () => ({ pid: 42, uptime: 7 }), + }, + }); + + const response = await sendCommand({ + socketPath, + method: 'getStatus', + timeoutMs: 2_000, + }); + + expect(response).toMatchObject({ + jsonrpc: '2.0', + result: { pid: 42, uptime: 7 }, + }); + }); + + it('returns responsive from pingDaemon when the server is up', async () => { + await startServer({ + socketPath, + handlers: { + getStatus: async () => ({ pid: 1, uptime: 0 }), + }, + }); + + expect(await pingDaemon(socketPath)).toStrictEqual({ + status: 'responsive', + }); + }); + + it('returns absent from pingDaemon when no socket exists', async () => { + expect(await pingDaemon(socketPath)).toStrictEqual({ status: 'absent' }); + }); + + it('surfaces handler errors to the client as JSON-RPC errors', async () => { + await startServer({ + socketPath, + handlers: { + boom: async () => { + throw new Error('handler exploded'); + }, + }, + }); + + const response = await sendCommand({ + socketPath, + method: 'boom', + timeoutMs: 2_000, + }); + + expect(response).toMatchObject({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32603, + message: 'handler exploded', + }), + }); + }); + + it('returns methodNotFound for unknown methods', async () => { + await startServer({ + socketPath, + handlers: { + getStatus: async () => ({ pid: 1, uptime: 0 }), + }, + }); + + const response = await sendCommand({ + socketPath, + method: 'doesNotExist', + timeoutMs: 2_000, + }); + + expect(response).toMatchObject({ + jsonrpc: '2.0', + error: expect.objectContaining({ code: -32601 }), + }); + }); + + it('handles concurrent in-flight requests without bleeding buffers across connections', async () => { + await startServer({ + socketPath, + handlers: { + echo: async (params) => ({ params }), + }, + }); + + const responses = await Promise.all( + Array.from({ length: 8 }, async (_value, index) => + sendCommand({ + socketPath, + method: 'echo', + params: [`request-${index}`], + timeoutMs: 2_000, + }), + ), + ); + + for (const [index, response] of responses.entries()) { + expect(response).toMatchObject({ + result: { params: [`request-${index}`] }, + }); + } + }); + + it('intercepts the `shutdown` method and fires the onShutdown callback', async () => { + const onShutdown = jest.fn().mockResolvedValue(undefined); + await startServer({ + socketPath, + handlers: {}, + onShutdown, + }); + + const response = await sendCommand({ + socketPath, + method: 'shutdown', + timeoutMs: 2_000, + }); + + expect(response).toMatchObject({ + result: { status: 'shutting down' }, + }); + + // onShutdown fires via setTimeout(..., 0) after the response is written. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onShutdown).toHaveBeenCalledTimes(1); + }); + + it('rejects pipelined requests with an invalidRequest error', async () => { + await startServer({ + socketPath, + handlers: { + getStatus: async () => ({ pid: 1, uptime: 0 }), + }, + }); + + // Open a raw connection and write two requests at once. + const { createConnection } = await import('node:net'); + const socket = createConnection(socketPath); + await new Promise((resolve, reject) => { + socket.once('connect', () => resolve()); + socket.once('error', reject); + }); + + socket.write( + `${JSON.stringify({ jsonrpc: '2.0', id: '1', method: 'getStatus' })}\n` + + `${JSON.stringify({ jsonrpc: '2.0', id: '2', method: 'getStatus' })}\n`, + ); + + const responseLine = await new Promise((resolve, reject) => { + let buffer = ''; + const onData = (chunk: Buffer): void => { + buffer += chunk.toString(); + const idx = buffer.indexOf('\n'); + if (idx !== -1) { + socket.removeListener('data', onData); + resolve(buffer.slice(0, idx)); + } + }; + socket.on('data', onData); + socket.once('error', reject); + }); + socket.destroy(); + + const response: unknown = JSON.parse(responseLine); + expect(response).toMatchObject({ + jsonrpc: '2.0', + error: expect.objectContaining({ code: -32600 }), + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts index 59e1b7f02fe..75753cf49ed 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.test.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -20,6 +20,7 @@ const ABSENT = { status: 'absent' as const }; const RESPONSIVE = { status: 'responsive' as const }; const UNREACHABLE = { status: 'unreachable' as const, + reason: 'refused' as const, error: new Error('refused'), }; diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts index d7976b0ac7a..a4cdefb78e3 100644 --- a/packages/wallet-cli/src/daemon/utils.ts +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -9,8 +9,14 @@ import { readFile } from 'node:fs/promises'; * @returns True if the error matches the code. */ export function isErrorWithCode(error: unknown, code: string): boolean { + // Duck-typed rather than `instanceof Error` so error values that crossed a + // realm boundary (e.g. from Node built-ins under jest's + // `--experimental-vm-modules`) still pass. return ( - error instanceof Error && hasProperty(error, 'code') && error.code === code + typeof error === 'object' && + error !== null && + hasProperty(error, 'code') && + error.code === code ); } diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts index 00b1e83f303..f6f0269ade6 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -161,6 +161,26 @@ describe('createWallet', () => { wallet.messenger, wallet.controllerMetadata, store, + undefined, + ); + + subscribeSpy.mockRestore(); + store.close(); + }); + + it('forwards the supplied log callback to subscribeToChanges', async () => { + const subscribeSpy = jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(() => undefined); + const log = jest.fn(); + + const { store } = await createWallet({ ...CONFIG, log }); + + expect(subscribeSpy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + log, ); subscribeSpy.mockRestore(); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 810cd12820f..08a95f98331 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -42,6 +42,7 @@ export type CreateWalletResult = { * @param config.infuraProjectId - The Infura project ID for network access. * @param config.password - The wallet password. * @param config.srp - The secret recovery phrase (BIP-39 mnemonic). + * @param config.log - Optional logger for persistence-write failures. * @returns The Wallet instance and the underlying KeyValueStore. The caller * owns the store and must close it after destroying the wallet (closing * first would cause in-flight persistence writes during teardown to fail). @@ -51,11 +52,18 @@ export async function createWallet({ infuraProjectId, password, srp, + log, }: { databasePath: string; infuraProjectId: string; password: string; srp: string; + /** + * Optional logger for persistence-write failures. Without it, failures + * fall back to `console.error` (which a detached daemon's + * `stdio: 'ignore'` discards). + */ + log?: (message: string) => void; }): Promise { const store = new KeyValueStore(databasePath); let wallet: Wallet | undefined; @@ -82,7 +90,7 @@ export async function createWallet({ getMetaMetricsId: (): string => 'cli', }); - subscribeToChanges(wallet.messenger, wallet.controllerMetadata, store); + subscribeToChanges(wallet.messenger, wallet.controllerMetadata, store, log); if (wasFirstRun) { await importSecretRecoveryPhrase(wallet, password, srp); diff --git a/packages/wallet-cli/src/persistence/persistence.test.ts b/packages/wallet-cli/src/persistence/persistence.test.ts index b4f8c660a83..6695fc76954 100644 --- a/packages/wallet-cli/src/persistence/persistence.test.ts +++ b/packages/wallet-cli/src/persistence/persistence.test.ts @@ -258,7 +258,7 @@ describe('subscribeToChanges', () => { expect(store.get('TestController.transient')).toBeUndefined(); }); - it('logs and continues when store.set throws', () => { + it('routes store.set failures through the supplied log callback', () => { const { messenger, controllerMetadata } = createMockControllers({ TestController: createStateMetadata([ ['propA', true], @@ -266,7 +266,8 @@ describe('subscribeToChanges', () => { ]), }); - subscribeToChanges(messenger, controllerMetadata, store); + const log = jest.fn(); + subscribeToChanges(messenger, controllerMetadata, store, log); const error = new Error('disk full'); const originalSet = store.set.bind(store); @@ -279,10 +280,6 @@ describe('subscribeToChanges', () => { originalSet(key, value); }); - const consoleSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => undefined); - publishStateChanged(messenger, 'TestController', { state: { propA: 'a', propB: 'b' }, patches: [ @@ -291,12 +288,41 @@ describe('subscribeToChanges', () => { ], }); - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to persist state for TestController.propA', - error, + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to persist state for TestController.propA', + ), ); // propB should still be persisted despite propA failing expect(store.get('TestController.propB')).toBe('b'); + }); + + it('falls back to console.error when no log callback is supplied', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['prop', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const error = new Error('disk full'); + jest.spyOn(store, 'set').mockImplementationOnce(() => { + throw error; + }); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'value' }, + patches: [{ op: 'replace', path: ['prop'], value: 'value' }], + }); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to persist state for TestController.prop', + ), + ); consoleSpy.mockRestore(); }); diff --git a/packages/wallet-cli/src/persistence/persistence.ts b/packages/wallet-cli/src/persistence/persistence.ts index 1297d14face..eea11fdfd30 100644 --- a/packages/wallet-cli/src/persistence/persistence.ts +++ b/packages/wallet-cli/src/persistence/persistence.ts @@ -76,6 +76,9 @@ export function loadState( * @param messenger - The root messenger to subscribe on. * @param controllerMetadata - A map from controller name to its state metadata. * @param store - The key-value store to write to. + * @param log - Optional logger for persistence-write failures. Without a + * logger, failures fall back to `console.error` (which the daemon's + * `stdio: 'ignore'` discards in production). * @returns A function that unsubscribes all persistence handlers. */ export function subscribeToChanges( @@ -84,8 +87,14 @@ export function subscribeToChanges( Record> >, store: KeyValueStore, + log?: (message: string) => void, ): () => void { const unsubscribers: (() => void)[] = []; + const logFn = + log ?? + ((message: string): void => { + console.error(message); + }); for (const [controllerName, metadata] of Object.entries(controllerMetadata)) { const persistedProperties = getPersistPropertyNames(metadata); @@ -115,8 +124,10 @@ export function subscribeToChanges( store.set(key, state[prop]); } } catch (error) { - // TODO: Handle persistence failure to protect the user from data loss. - console.error(`Failed to persist state for ${key}`, error); + // TODO: Surface persistence-write failures up the stack so callers + // can decide to halt rather than continue with diverging in-memory + // and on-disk state. For now, log and continue. + logFn(`Failed to persist state for ${key}: ${String(error)}`); } } }; diff --git a/packages/wallet-cli/src/test/run-command.ts b/packages/wallet-cli/src/test/run-command.ts new file mode 100644 index 00000000000..de100c3ca2b --- /dev/null +++ b/packages/wallet-cli/src/test/run-command.ts @@ -0,0 +1,86 @@ +import { Command } from '@oclif/core'; +import type { Config } from '@oclif/core'; +import { CLIError } from '@oclif/core/errors'; + +type CommandCtor = new ( + argv: string[], + config: Config, +) => Command & { + _run: () => Promise; +}; + +const TEST_DATA_DIR = '/tmp/mm-cli-test-data'; +const TEST_PACKAGE_ROOT = '/tmp/mm-cli-test-root'; + +/** + * Invoke an oclif command class with the given argv and return the captured + * stdout/stderr/error so tests can assert on them. + * + * Bypasses `Command.run`'s static plugin-loading path (which requires a real + * `Config.load`) by constructing the command instance directly with a + * hand-rolled `Config` and invoking the protected `_run`. Spies on the + * Command prototype so `this.log` and `this.error` go to local buffers + * instead of stdout/stderr. + * + * @param CommandClass - The command class (a subclass of `@oclif/core` Command). + * @param argv - Command-line tokens (flags + positional args). + * @returns Captured stdout, stderr, and any `this.error()` payload. + */ +export async function runCommand( + CommandClass: CommandCtor, + argv: string[] = [], +): Promise<{ + stdout: string; + stderr: string; + error: CLIError | undefined; +}> { + let stdout = ''; + let stderr = ''; + let error: CLIError | undefined; + + const fakeConfig = { + dataDir: TEST_DATA_DIR, + root: TEST_PACKAGE_ROOT, + bin: 'mm', + name: '@metamask/wallet-cli', + version: '0.0.0-test', + pjson: { name: '@metamask/wallet-cli', version: '0.0.0-test' }, + findCommand: () => undefined, + runHook: async () => ({ successes: [], failures: [] }), + scopedEnvVar: () => undefined, + scopedEnvVarKey: () => '', + scopedEnvVarKeys: () => [], + scopedEnvVarTrue: () => false, + plugins: new Map(), + flexibleTaxonomy: false, + } as unknown as Config; + + const logSpy = jest + .spyOn(Command.prototype, 'log') + .mockImplementation((message: unknown = '') => { + stdout += `${String(message)}\n`; + }); + const errorSpy = jest + .spyOn(Command.prototype, 'error') + .mockImplementation((input: string | Error) => { + const message = typeof input === 'string' ? input : input.message; + throw new CLIError(message); + }); + + try { + const instance = new CommandClass(argv, fakeConfig); + await instance._run(); + } catch (caught: unknown) { + if (caught instanceof CLIError) { + error = caught; + stderr += `${caught.message}\n`; + } else { + throw caught; + } + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + + return { stdout, stderr, error }; +} From 7c24e36663d574a8641663707f173071a8803805 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 12 May 2026 18:01:04 +0200 Subject: [PATCH 30/32] fix(wallet-cli): Build, race, security follow-ups from Codex review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External review caught issues that the prior local validation missed because I only ran tests + lint, not `yarn build`. Build (now passes `yarn build` from the monorepo root) - call.ts: type rpcParams as `Json[]` instead of `unknown[]` so the `params` field actually satisfies `JsonRpcParams`. - purge.ts / prompts.ts: factor the `@inquirer/confirm` dynamic import out into a small `prompts.ts` wrapper. The previous static import broke ts-bridge's CJS emit because the package is ESM-only. - daemon-spawn.ts: rework exitInfo to a tagged container so TS can re-narrow it after the await boundary. - persistence.ts: keep the two `@ts-expect-error` directives on the dynamically-constructed event subscriptions — they were stale under the workspace-local build (which loses event-type information) but load-bearing under the root build, where the messenger's full event union is visible. Concurrency: write PID before opening the database - The order was `claimDaemonSlot → createWallet → wx write`. Two concurrent `daemon start` invocations could both pass the preflight, both open `/wallet.db`, and both run first-run SRP import before one lost the wx race. Move the wx write before `createWallet` so the loser fails fast without touching the DB. Adjust cleanup paths to handle wallet/store being undefined. - `claimDaemonSlot` now also refuses to clobber when the recorded PID is alive AND the socket is absent (e.g. sibling daemon mid-startup before bind, or socket manually rm'd from under a live daemon). Symmetric with the unreachable+alive case. Security: filesystem permissions - `mkdirSync(dataDir, { recursive: true, mode: 0o700 })` so another local user cannot traverse into the daemon's data dir. - `chmod(socketPath, 0o600)` immediately after listen() so the socket is owner-only. The daemon explicitly exposes the full wallet messenger to anyone who can connect, so these permissions are the only access boundary. Docs - `packages/wallet/CHANGELOG.md`: link PR #8446 alongside the issue, matching AGENTS.md's PR-link convention. 241 wallet-cli tests pass at 100% coverage; 9 wallet tests pass. `yarn build`, `yarn lint:eslint`, `yarn lint:misc:check`, `yarn constraints`, both `changelog:validate`s all clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wallet-cli/src/commands/daemon/call.ts | 8 +- .../src/commands/daemon/prompts.test.ts | 38 +++++ .../wallet-cli/src/commands/daemon/prompts.ts | 16 ++ .../src/commands/daemon/purge.test.ts | 6 +- .../wallet-cli/src/commands/daemon/purge.ts | 7 +- .../src/daemon/daemon-entry.test.ts | 48 ++++-- .../wallet-cli/src/daemon/daemon-entry.ts | 150 +++++++++++------- .../wallet-cli/src/daemon/daemon-spawn.ts | 12 +- packages/wallet/CHANGELOG.md | 2 +- 9 files changed, 201 insertions(+), 86 deletions(-) create mode 100644 packages/wallet-cli/src/commands/daemon/prompts.test.ts create mode 100644 packages/wallet-cli/src/commands/daemon/prompts.ts diff --git a/packages/wallet-cli/src/commands/daemon/call.ts b/packages/wallet-cli/src/commands/daemon/call.ts index 2e443efad40..37a902439b9 100644 --- a/packages/wallet-cli/src/commands/daemon/call.ts +++ b/packages/wallet-cli/src/commands/daemon/call.ts @@ -1,3 +1,4 @@ +import type { Json } from '@metamask/utils'; import { isJsonRpcFailure } from '@metamask/utils'; import { Args, Command, Flags } from '@oclif/core'; @@ -39,7 +40,10 @@ export default class DaemonCall extends Command { const { action } = args; const timeoutMs = flags.timeout; - let rpcParams: unknown[] = [action]; + // The daemon's `call` RPC expects `[action, ...args]`. `JSON.parse` returns + // `unknown`, but anything it produces is structurally `Json`, so we cast to + // `Json[]` once we've confirmed the parsed payload is an array. + const rpcParams: Json[] = [action]; if (args.params !== undefined) { let parsed: unknown; try { @@ -52,7 +56,7 @@ export default class DaemonCall extends Command { this.error('params must be a JSON array'); } - rpcParams = [action, ...parsed]; + rpcParams.push(...(parsed as Json[])); } const { socketPath } = getDaemonPaths(this.config.dataDir); diff --git a/packages/wallet-cli/src/commands/daemon/prompts.test.ts b/packages/wallet-cli/src/commands/daemon/prompts.test.ts new file mode 100644 index 00000000000..6ec63c50d2e --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/prompts.test.ts @@ -0,0 +1,38 @@ +// `@inquirer/confirm` is ESM-only and `prompts.ts` reaches it via a dynamic +// `import()`. Use jest's ESM mock API and dynamic imports to mirror that. +// The import statement below is what tags this file as a module for the +// `import-x/unambiguous` lint rule, even though it imports only the type. +import type Confirm from '@inquirer/confirm'; + +jest.unstable_mockModule('@inquirer/confirm', () => ({ + __esModule: true, + default: jest.fn(), +})); + +type ConfirmMock = jest.MockedFunction; + +describe('confirmPurge', () => { + it('invokes @inquirer/confirm with the purge prompt and returns its result', async () => { + const confirm = (await import('@inquirer/confirm')) + .default as unknown as ConfirmMock; + confirm.mockResolvedValue(true); + const { confirmPurge } = await import('./prompts'); + + const result = await confirmPurge(); + + expect(result).toBe(true); + expect(confirm).toHaveBeenCalledWith({ + message: 'This will stop the daemon and delete all state. Continue?', + default: false, + }); + }); + + it('returns false when the user declines', async () => { + const confirm = (await import('@inquirer/confirm')) + .default as unknown as ConfirmMock; + confirm.mockResolvedValue(false); + const { confirmPurge } = await import('./prompts'); + + expect(await confirmPurge()).toBe(false); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/prompts.ts b/packages/wallet-cli/src/commands/daemon/prompts.ts new file mode 100644 index 00000000000..4245b680503 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/prompts.ts @@ -0,0 +1,16 @@ +/** + * Ask the user to confirm the destructive `daemon purge` operation. + * + * Wraps `@inquirer/confirm` in a dynamic import so this CommonJS-compiled + * package can interop with that ESM-only dependency, and so tests can mock + * the prompt without going through jest's ESM mock machinery. + * + * @returns True if the user confirmed. + */ +export async function confirmPurge(): Promise { + const { default: confirm } = await import('@inquirer/confirm'); + return confirm({ + message: 'This will stop the daemon and delete all state. Continue?', + default: false, + }); +} diff --git a/packages/wallet-cli/src/commands/daemon/purge.test.ts b/packages/wallet-cli/src/commands/daemon/purge.test.ts index 6526e38f254..adc75f2cd94 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.test.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.test.ts @@ -1,17 +1,17 @@ -import confirm from '@inquirer/confirm'; import { rm } from 'node:fs/promises'; import { pingDaemon } from '../../daemon/daemon-client'; import { stopDaemon } from '../../daemon/stop-daemon'; import { runCommand } from '../../test/run-command'; +import { confirmPurge } from './prompts'; import DaemonPurge from './purge'; jest.mock('node:fs/promises'); jest.mock('../../daemon/daemon-client'); jest.mock('../../daemon/stop-daemon'); -jest.mock('@inquirer/confirm'); +jest.mock('./prompts'); -const inquirerConfirm = jest.mocked(confirm); +const inquirerConfirm = jest.mocked(confirmPurge); const mockRm = jest.mocked(rm); const mockPingDaemon = jest.mocked(pingDaemon); const mockStopDaemon = jest.mocked(stopDaemon); diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts index 9792aec6a0e..947547bcdbb 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -1,10 +1,10 @@ -import confirm from '@inquirer/confirm'; import { Command, Flags } from '@oclif/core'; import { rm } from 'node:fs/promises'; import { pingDaemon } from '../../daemon/daemon-client'; import { getDaemonPaths } from '../../daemon/paths'; import { stopDaemon } from '../../daemon/stop-daemon'; +import { confirmPurge } from './prompts'; export default class DaemonPurge extends Command { static override description = @@ -26,10 +26,7 @@ export default class DaemonPurge extends Command { const { flags } = await this.parse(DaemonPurge); if (!flags.force) { - const confirmed = await confirm({ - message: 'This will stop the daemon and delete all state. Continue?', - default: false, - }); + const confirmed = await confirmPurge(); if (!confirmed) { this.log('Aborted.'); return; diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index e48f53ec900..6b81d63da20 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -183,6 +183,7 @@ describe('daemon-entry', () => { expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/data', { recursive: true, + mode: 0o700, }); expect(mockCreateWallet).toHaveBeenCalledWith({ databasePath: '/tmp/wallet.db', @@ -329,33 +330,25 @@ describe('daemon-entry', () => { it('aborts when another daemon wins the exclusive PID-file write race', async () => { // Simulate two daemons reaching the wx write nearly simultaneously: pre-flight // sees no PID file (ENOENT), but writeFile rejects with EEXIST because a - // sibling already claimed the slot. The cleanup ownership check must NOT - // remove the sibling's PID file. - const result = createMockWallet(); - mockCreateWallet.mockResolvedValue(result); + // sibling already claimed the slot. Since the slot write now happens BEFORE + // createWallet, we never construct a wallet or open the DB. const eexist = Object.assign(new Error('already exists'), { code: 'EEXIST', }); mockWriteFile.mockRejectedValue(eexist); - mockReadFile - .mockRejectedValueOnce(enoent()) // pre-flight readPidFile: no file yet - .mockResolvedValueOnce('99999\n9999999\n'); // ownership-check sees sibling await importDaemonEntry(); expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining('already exists'), ); - expect(process.exitCode).toBe(1); - expect(result.wallet.destroy).toHaveBeenCalled(); - expect(result.store.close).toHaveBeenCalled(); - // Critical: removeOwnedPidFile saw the sibling's contents and refused - // to delete. The only rm of pidPath should be the pre-flight cleanup - // call (which is a no-op when the file doesn't exist yet). - const pidRmCalls = mockRm.mock.calls.filter( - ([path]) => path === '/tmp/daemon.pid', + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to claim daemon slot'), ); - expect(pidRmCalls).toHaveLength(1); + expect(process.exitCode).toBe(1); + // Wallet must NOT be constructed when the slot write loses the race — + // this is the whole point of writing the PID before opening the DB. + expect(mockCreateWallet).not.toHaveBeenCalled(); }); it('refuses to take over an unreachable socket whose recorded PID is alive', async () => { @@ -367,7 +360,28 @@ describe('daemon-entry', () => { await importDaemonEntry(); expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('A daemon is already running but its socket'), + expect.stringContaining('A daemon is already running'), + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('socket at /tmp/daemon.sock is unresponsive'), + ); + expect(process.exitCode).toBe(1); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('refuses to take over when the socket is absent but the recorded PID is alive', async () => { + mockReadFile.mockResolvedValue('9999\n12345\n'); + mockPingDaemon.mockResolvedValue(ABSENT); + mockIsProcessAlive.mockReturnValue(true); + mockCreateWallet.mockResolvedValue(createMockWallet()); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('A daemon is already running'), + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('pid is still alive'), ); expect(process.exitCode).toBe(1); expect(mockWriteFile).not.toHaveBeenCalled(); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 408940dac59..60c480c0e50 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -1,7 +1,9 @@ import type { Json } from '@metamask/utils'; +import type { Wallet } from '@metamask/wallet'; import { mkdirSync } from 'node:fs'; -import { appendFile, readFile, rm, writeFile } from 'node:fs/promises'; +import { appendFile, chmod, readFile, rm, writeFile } from 'node:fs/promises'; +import type { KeyValueStore } from '../persistence/KeyValueStore'; import { pingDaemon } from './daemon-client'; import { getDaemonPaths } from './paths'; import { startRpcSocketServer } from './rpc-socket-server'; @@ -41,7 +43,11 @@ async function main(): Promise { throw new Error('MM_WALLET_SRP environment variable is required'); } - mkdirSync(dataDir, { recursive: true }); + // 0o700: owner-only. The daemon exposes the full wallet messenger over + // the socket inside this directory, so anyone who can traverse the dir + // can also `connect()` to the socket. Restricting to the owning user is + // the only access-control boundary. + mkdirSync(dataDir, { recursive: true, mode: 0o700 }); const { socketPath: defaultSocketPath, @@ -61,40 +67,61 @@ async function main(): Promise { const pidFileContents = `${process.pid}\n${startTime}\n`; - const { wallet, store } = await createWallet({ - databasePath: dbPath, - infuraProjectId, - password, - srp, - log, - }); + // Claim the slot atomically BEFORE opening the SQLite database or + // constructing the Wallet. Two concurrent `daemon start` invocations can + // both pass `claimDaemonSlot` (the gap between its preflight and the slot + // write is racy); without this ordering, both would open `wallet.db` and + // both would run first-run SRP import before one loses the wx race. + try { + await writeFile(pidPath, pidFileContents, { flag: 'wx' }); + } catch (error) { + throw error instanceof Error + ? Object.assign(error, { + message: `Failed to claim daemon slot at ${pidPath}: ${error.message}`, + }) + : /* istanbul ignore next -- node:fs/promises always rejects with an Error */ + new Error( + `Failed to claim daemon slot at ${pidPath}: ${String(error)}`, + ); + } - const handlers: RpcHandlerMap = { - getStatus: async (): Promise => ({ - pid: process.pid, - uptime: Math.floor((Date.now() - startTime) / 1000), - }), - // Arbitrary messenger dispatch is intentional: the CLI exposes the full - // messenger surface over a Unix socket inside the per-user oclif data - // directory. Anything that can open that path can call into the wallet — - // no in-process auth check is performed. - call: async (params) => { - if (!Array.isArray(params) || typeof params[0] !== 'string') { - throw new Error('Expected params to be an array with an action name'); - } - const [action, ...args] = params as [string, ...Json[]]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC. - const result = (wallet.messenger as any).call(action, ...args); - return (result instanceof Promise ? await result : result) as Json; - }, - }; + let wallet: Wallet | undefined; + let store: KeyValueStore | undefined; + let handle: RpcSocketServerHandle | undefined; - let handle: RpcSocketServerHandle; try { - // Exclusive create — if another daemon raced us between claimDaemonSlot - // and here, this fails with EEXIST and we abort rather than orphan the - // sibling daemon's PID file. - await writeFile(pidPath, pidFileContents, { flag: 'wx' }); + ({ wallet, store } = await createWallet({ + databasePath: dbPath, + infuraProjectId, + password, + srp, + log, + })); + + const constructedWallet = wallet; + const handlers: RpcHandlerMap = { + getStatus: async (): Promise => ({ + pid: process.pid, + uptime: Math.floor((Date.now() - startTime) / 1000), + }), + // Arbitrary messenger dispatch is intentional: the CLI exposes the full + // messenger surface over a Unix socket inside the per-user oclif data + // directory. The dataDir/socket are chmodded to 0o700/0o600 below so + // only the owning user can open them, but there is no in-process + // auth check beyond that filesystem-permission barrier. + call: async (params) => { + if (!Array.isArray(params) || typeof params[0] !== 'string') { + throw new Error('Expected params to be an array with an action name'); + } + const [action, ...args] = params as [string, ...Json[]]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC. + const result = (constructedWallet.messenger as any).call( + action, + ...args, + ); + return (result instanceof Promise ? await result : result) as Json; + }, + }; handle = await startRpcSocketServer({ socketPath, @@ -102,16 +129,23 @@ async function main(): Promise { onShutdown: async () => shutdown('RPC shutdown'), log, }); + // Restrict the socket to the owner. listen() emits 'listening' + // synchronously, so this runs before any client can connect. + await chmod(socketPath, 0o600); } catch (error) { - try { - await wallet.destroy(); - } catch (destroyError) { - log(`wallet.destroy() failed during cleanup: ${String(destroyError)}`); + if (wallet) { + try { + await wallet.destroy(); + } catch (destroyError) { + log(`wallet.destroy() failed during cleanup: ${String(destroyError)}`); + } } - try { - store.close(); - } catch (closeError) { - log(`store.close() failed during cleanup: ${String(closeError)}`); + if (store) { + try { + store.close(); + } catch (closeError) { + log(`store.close() failed during cleanup: ${String(closeError)}`); + } } // Only remove the PID file if it's still ours (we may have lost the race // and the file now belongs to another daemon). @@ -123,6 +157,12 @@ async function main(): Promise { throw error; } + // Capture the now-resolved bindings so the shutdown closures below have + // a stable, non-undefined reference (TS narrowing across closure escape). + const activeHandle = handle; + const activeWallet = wallet; + const activeStore = store; + log(`Daemon started. Socket: ${socketPath}`); let shutdownPromise: Promise | undefined; @@ -138,17 +178,17 @@ async function main(): Promise { log(`Shutting down (${reason})...`); shutdownPromise = (async (): Promise => { try { - await handle.close(); + await activeHandle.close(); } catch (closeError) { log(`handle.close() failed: ${String(closeError)}`); } try { - await wallet.destroy(); + await activeWallet.destroy(); } catch (destroyError) { log(`wallet.destroy() failed: ${String(destroyError)}`); } try { - store.close(); + activeStore.close(); } catch (closeError) { log(`store.close() failed: ${String(closeError)}`); } @@ -202,16 +242,20 @@ async function claimDaemonSlot( throw new Error(`A daemon is already running on ${socketPath} ${pidPart}`); } - if ( - ping.status === 'unreachable' && - existingPid !== undefined && - isProcessAlive(existingPid) - ) { - // Symmetric with `ensureDaemon`: do not silently take over a wedged - // sibling daemon. The user should `mm daemon stop` (or `purge`) first. + // Refuse to clobber when the recorded PID is still alive, regardless of + // whether the socket exists. Possible scenarios: + // - `unreachable`: wedged or mid-startup sibling daemon (socket present + // but not responding to JSON-RPC). + // - `absent`: a sibling daemon that hasn't yet bound its socket, or one + // whose socket was manually removed. In either case, removing its PID + // file would orphan it from `daemon stop`. + if (existingPid !== undefined && isProcessAlive(existingPid)) { + const detail = + ping.status === 'unreachable' + ? `socket at ${socketPath} is unresponsive (${ping.error.message})` + : `no socket at ${socketPath}, but pid is still alive`; throw new Error( - `A daemon is already running but its socket at ${socketPath} is unresponsive ` + - `(pid ${existingPid}, ${ping.error.message}). ` + + `A daemon is already running (pid ${existingPid}): ${detail}. ` + `Run \`mm daemon stop\` (or \`mm daemon purge\`) before starting a new daemon.`, ); } diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index 1f6a2414bc1..834ed8b2675 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -76,21 +76,23 @@ export async function ensureDaemon( }, }); - let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = - null; + type ExitInfo = { code: number | null; signal: NodeJS.Signals | null }; + const exitInfo: { value: ExitInfo | null } = { value: null }; + child.on('error', (error) => { process.stderr.write(`Failed to spawn daemon process: ${String(error)}\n`); }); child.on('exit', (code, signal) => { - exitInfo = { code, signal }; + exitInfo.value = { code, signal }; }); child.unref(); for (let i = 0; i < MAX_POLLS; i++) { await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - if (exitInfo !== null) { + if (exitInfo.value !== null) { + const { code, signal } = exitInfo.value; throw new Error( - `Daemon process exited during startup (code=${String(exitInfo.code)}, signal=${String(exitInfo.signal)}). ` + + `Daemon process exited during startup (code=${String(code)}, signal=${String(signal)}). ` + `Check the daemon log at ${getDaemonPaths(config.dataDir).logPath}.`, ); } diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 20770a57ec2..4dc7ee9545f 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -- **BREAKING:** Drop the `better-sqlite3`-backed persistence layer and `./persistence` subpath export ([#8682](https://github.com/MetaMask/core/issues/8682)) +- **BREAKING:** Drop the `better-sqlite3`-backed persistence layer and `./persistence` subpath export ([#8446](https://github.com/MetaMask/core/pull/8446), [#8682](https://github.com/MetaMask/core/issues/8682)) - The `KeyValueStore`, `loadState`, and `subscribeToChanges` exports previously available via `@metamask/wallet/persistence` have been removed. - The `better-sqlite3` and `@types/better-sqlite3` dependencies are no longer pulled in by this package. - Consumers should keep their own persistence layer and inject controller state through the existing `state` option on `Wallet`. A Node-only SQLite implementation now lives in `@metamask/wallet-cli`. From 603ab5154206708a2fc57be1764efabf62464875 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 12 May 2026 18:11:48 +0200 Subject: [PATCH 31/32] fix(wallet-cli): Plug prototype-method bypass + unblock Node 18 CI - `rpc-socket-server`: gate the handler lookup behind `Object.prototype.hasOwnProperty.call(handlers, method)` so a request with `method: "toString"` (or `"constructor"`, etc.) returns `methodNotFound` instead of resolving to an Object.prototype member. Closes the Cursor Bugbot prototype-method-invocation finding. - CI: `better-sqlite3@12.9.0` only ships prebuilt binaries for Node 20+ (declared `engines.node = "20.x || 22.x || 23.x || 24.x | 25.x"`), so the wallet-cli test job on 18.x fails when `prebuild-install` can't find a matching binary. Mirror the existing exclusion for `@metamask/wallet` in `.github/workflows/lint-build-test.yml`, bump `packages/wallet-cli`'s declared `engines.node` to `>=20`, and update the yarn constraint to allow that single deviation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/lint-build-test.yml | 2 ++ packages/wallet-cli/package.json | 2 +- .../src/daemon/rpc-socket-server.test.ts | 23 +++++++++++++++++++ .../src/daemon/rpc-socket-server.ts | 4 +++- yarn.config.cjs | 9 +++++++- 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 09faaeb6f0c..fdba6971c12 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -146,6 +146,8 @@ jobs: exclude: - node-version: 18.x package-name: '@metamask/wallet' + - node-version: 18.x + package-name: '@metamask/wallet-cli' steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v2 diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index f5d2c9a7412..000c5b4a7ee 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -74,6 +74,6 @@ "topicSeparator": " " }, "engines": { - "node": "^18.18 || >=20" + "node": ">=20" } } diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts index 210d964fdcd..2fcea6ff95b 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -289,6 +289,29 @@ describe('startRpcSocketServer', () => { ); }); + it.each(['toString', 'constructor', 'hasOwnProperty', '__proto__'])( + 'returns -32601 for the Object.prototype name %p instead of invoking the inherited member', + async (method) => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32601, + }), + ); + }, + ); + it('returns -32603 when handler throws an Error', async () => { const { simulateConnection } = createMockServer(); const handlers: RpcHandlerMap = { diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 76d45aefab9..07c8a3ed7b2 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -227,7 +227,9 @@ async function handleRequest( return { jsonrpc: '2.0', id, result: { status: 'shutting down' } }; } - const handler = handlers[method]; + const handler = Object.prototype.hasOwnProperty.call(handlers, method) + ? handlers[method] + : undefined; if (!handler) { return { jsonrpc: '2.0', diff --git a/yarn.config.cjs b/yarn.config.cjs index bbaffece7ef..e707a7a7cee 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -253,7 +253,14 @@ module.exports = defineConfig({ } // All packages must specify a minimum Node.js version of 18.18. - expectWorkspaceField(workspace, 'engines.node', '^18.18 || >=20'); + // @metamask/wallet-cli depends on `better-sqlite3` which only ships + // prebuilt binaries for Node 20+; bumping its declared minimum keeps + // the engines field honest. + if (workspace.ident === '@metamask/wallet-cli') { + expectWorkspaceField(workspace, 'engines.node', '>=20'); + } else { + expectWorkspaceField(workspace, 'engines.node', '^18.18 || >=20'); + } // All non-root public packages should be published to the NPM registry; // all non-root private packages should not. From b2d1f5480da8550918fc68d59ae362928adbf75b Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 12 May 2026 18:30:46 +0200 Subject: [PATCH 32/32] fix(wallet-cli): Apply remaining review follow-ups - stop-daemon: signal the recorded PID when the socket is absent but the process is still alive. Previous behaviour treated the PID file as stale, returned success, and let `purge` wipe the SQLite DB while a live daemon still held handles. Trades a small recycled-PID risk for closing a real DB-corruption window. - daemon-entry: `chmod(dataDir, 0o700)` after `mkdirSync`. The `mode` option is ignored when the directory already exists, so first-run- with-existing-dir installs were leaving the dir at default umask. - Move `prompts.ts` out of `src/commands/daemon/` and into `src/daemon/` so oclif no longer discovers it as a `daemon:prompts` command (visible as a `command daemon:prompts not found` warning in `mm --help`). - CODEOWNERS: list `@MetaMask/ocap-kernel` alongside `@MetaMask/core- platform` for `packages/wallet-cli` to match `teams.json`. - CHANGELOG: drop the issue link from the persistence entry (both packages); AGENTS.md asks for PR links, not issue links. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/CODEOWNERS | 2 +- packages/wallet-cli/CHANGELOG.md | 2 +- .../src/commands/daemon/purge.test.ts | 4 ++-- .../wallet-cli/src/commands/daemon/purge.ts | 2 +- .../wallet-cli/src/daemon/daemon-entry.ts | 4 +++- .../src/{commands => }/daemon/prompts.test.ts | 0 .../src/{commands => }/daemon/prompts.ts | 0 .../wallet-cli/src/daemon/stop-daemon.test.ts | 15 ++++++++---- packages/wallet-cli/src/daemon/stop-daemon.ts | 24 ++++++++++++------- packages/wallet/CHANGELOG.md | 2 +- 10 files changed, 34 insertions(+), 21 deletions(-) rename packages/wallet-cli/src/{commands => }/daemon/prompts.test.ts (100%) rename packages/wallet-cli/src/{commands => }/daemon/prompts.ts (100%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 99a206fb0fa..4041fa8e412 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -128,7 +128,7 @@ /packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform /packages/wallet @MetaMask/core-platform -/packages/wallet-cli @MetaMask/core-platform +/packages/wallet-cli @MetaMask/core-platform @MetaMask/ocap-kernel ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index d28247ff22c..1f4ed792e46 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -13,6 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mm daemon start` spawns the daemon with `--infura-project-id`, `--password`, `--srp` (or the matching env vars). - `mm daemon call []` dispatches any messenger action over JSON-RPC. - `mm daemon stop`, `mm daemon status`, `mm daemon purge` manage daemon lifecycle and state. -- Persist daemon state to a SQLite database at `/wallet.db`; subsequent `daemon start` runs reuse the persisted KeyringController vault instead of re-importing the SRP ([#8682](https://github.com/MetaMask/core/issues/8682)). +- Persist daemon state to a SQLite database at `/wallet.db`; subsequent `daemon start` runs reuse the persisted KeyringController vault instead of re-importing the SRP ([#8446](https://github.com/MetaMask/core/pull/8446)). [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/src/commands/daemon/purge.test.ts b/packages/wallet-cli/src/commands/daemon/purge.test.ts index adc75f2cd94..7189981e0cb 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.test.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.test.ts @@ -1,15 +1,15 @@ import { rm } from 'node:fs/promises'; import { pingDaemon } from '../../daemon/daemon-client'; +import { confirmPurge } from '../../daemon/prompts'; import { stopDaemon } from '../../daemon/stop-daemon'; import { runCommand } from '../../test/run-command'; -import { confirmPurge } from './prompts'; import DaemonPurge from './purge'; jest.mock('node:fs/promises'); jest.mock('../../daemon/daemon-client'); jest.mock('../../daemon/stop-daemon'); -jest.mock('./prompts'); +jest.mock('../../daemon/prompts'); const inquirerConfirm = jest.mocked(confirmPurge); const mockRm = jest.mocked(rm); diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts index 947547bcdbb..cd25660bf8c 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -3,8 +3,8 @@ import { rm } from 'node:fs/promises'; import { pingDaemon } from '../../daemon/daemon-client'; import { getDaemonPaths } from '../../daemon/paths'; +import { confirmPurge } from '../../daemon/prompts'; import { stopDaemon } from '../../daemon/stop-daemon'; -import { confirmPurge } from './prompts'; export default class DaemonPurge extends Command { static override description = diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 60c480c0e50..8a27f7df406 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -46,8 +46,10 @@ async function main(): Promise { // 0o700: owner-only. The daemon exposes the full wallet messenger over // the socket inside this directory, so anyone who can traverse the dir // can also `connect()` to the socket. Restricting to the owning user is - // the only access-control boundary. + // the only access-control boundary. We chmod after mkdir because the + // `mode` option is ignored when the directory already exists. mkdirSync(dataDir, { recursive: true, mode: 0o700 }); + await chmod(dataDir, 0o700); const { socketPath: defaultSocketPath, diff --git a/packages/wallet-cli/src/commands/daemon/prompts.test.ts b/packages/wallet-cli/src/daemon/prompts.test.ts similarity index 100% rename from packages/wallet-cli/src/commands/daemon/prompts.test.ts rename to packages/wallet-cli/src/daemon/prompts.test.ts diff --git a/packages/wallet-cli/src/commands/daemon/prompts.ts b/packages/wallet-cli/src/daemon/prompts.ts similarity index 100% rename from packages/wallet-cli/src/commands/daemon/prompts.ts rename to packages/wallet-cli/src/daemon/prompts.ts diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts index 75753cf49ed..f96c9257a81 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.test.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -50,16 +50,21 @@ describe('stopDaemon', () => { expect(mockSendSignal).not.toHaveBeenCalled(); }); - it('does not signal the recorded PID when the socket is absent even if isProcessAlive would say true', async () => { + it('signals the recorded PID when the socket is absent but the process is still alive', async () => { mockReadPidFile.mockResolvedValue(123); mockPingDaemon.mockResolvedValue(ABSENT); - // isProcessAlive should never be invoked in this branch, but guard the - // contract by also asserting no signal even if it would return true. mockIsProcessAlive.mockReturnValue(true); + mockSendSignal.mockReturnValue(true); + mockWaitFor.mockResolvedValueOnce(true); + + const log = jest.fn(); + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid', log); - const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); - expect(mockSendSignal).not.toHaveBeenCalled(); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGTERM'); + expect(log).toHaveBeenCalledWith( + expect.stringContaining('Socket at /tmp/test.sock is absent'), + ); }); it('stops daemon via graceful RPC shutdown', async () => { diff --git a/packages/wallet-cli/src/daemon/stop-daemon.ts b/packages/wallet-cli/src/daemon/stop-daemon.ts index d9a1d46ba94..f7a50b19f61 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.ts @@ -7,11 +7,12 @@ import { isProcessAlive, readPidFile, sendSignal, waitFor } from './utils'; * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if * the socket is unresponsive, and escalates to SIGKILL if SIGTERM is ignored. * - * Signals are only sent if pinging the socket yielded `responsive` or - * `unreachable` (i.e. we did not see `ENOENT`). When the ping is `absent` - * we decline to signal the recorded PID, because long-running workstations - * can recycle PIDs to unrelated processes; we just clean up the stale PID - * file. + * Signals are sent when EITHER the socket was observed (`responsive` or + * `unreachable`) OR the recorded PID is still alive on its own. The + * socket-absent + alive-PID branch trades a small risk of signalling a + * recycled PID for the larger risk of leaving an orphan daemon holding the + * SQLite database — which `daemon purge` would otherwise wipe out from + * under it. * * @param socketPath - The daemon socket path. * @param pidPath - The daemon PID file path. @@ -27,8 +28,7 @@ export async function stopDaemon( const ping = await pingDaemon(socketPath); const socketObserved = ping.status === 'responsive' || ping.status === 'unreachable'; - const processAlive = - pid !== undefined && socketObserved && isProcessAlive(pid); + const processAlive = pid !== undefined && isProcessAlive(pid); if (!socketObserved && !processAlive) { // No live daemon evidence. Just remove the stale PID file if any. @@ -53,9 +53,15 @@ export async function stopDaemon( ); } - // Strategy 2: SIGTERM. Only signal when we have evidence the socket - // belongs to a live process (socketObserved && processAlive). + // Strategy 2: SIGTERM. Signal when either the socket was observed or the + // recorded PID is alive; the absent+alive case typically means someone + // removed the socket from under a live daemon. if (!stopped && processAlive && pid !== undefined) { + if (!socketObserved) { + log?.( + `Socket at ${socketPath} is absent but recorded pid ${pid} is alive; signalling anyway.`, + ); + } try { if (sendSignal(pid, 'SIGTERM')) { stopped = await waitFor(() => !isProcessAlive(pid), 5_000); diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 4dc7ee9545f..af562148905 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -- **BREAKING:** Drop the `better-sqlite3`-backed persistence layer and `./persistence` subpath export ([#8446](https://github.com/MetaMask/core/pull/8446), [#8682](https://github.com/MetaMask/core/issues/8682)) +- **BREAKING:** Drop the `better-sqlite3`-backed persistence layer and `./persistence` subpath export ([#8446](https://github.com/MetaMask/core/pull/8446)) - The `KeyValueStore`, `loadState`, and `subscribeToChanges` exports previously available via `@metamask/wallet/persistence` have been removed. - The `better-sqlite3` and `@types/better-sqlite3` dependencies are no longer pulled in by this package. - Consumers should keep their own persistence layer and inject controller state through the existing `state` option on `Wallet`. A Node-only SQLite implementation now lives in `@metamask/wallet-cli`.