From a458746f795409b406601c9c0608d8c0b3c2bdbf Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 29 Jun 2026 17:24:41 +0200 Subject: [PATCH 01/13] test(wallet-cli): add subprocess daemon lifecycle e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an end-to-end suite that spawns the built `mm` CLI as real child processes and drives the full daemon lifecycle over the Unix socket — the gap left by the in-process suites (`socket-integration.test.ts` exercises the transport in-realm; `wallet-factory.e2e.test.ts` constructs a `Wallet` in-process). Covers: `start` (and the already-running guard on a second `start`), `call` returning the SRP-derived account, `status`, `stop`, persistence across a restart (the resume path: the wallet comes back locked rather than re-importing the SRP), `purge`, and the owner-only socket (0600) / data dir (0700). Because it needs the built `dist/` and the native better-sqlite3 addon, it runs as its own jest project (`jest.config.e2e.js`) via a new `test:e2e` script and is excluded from the fast unit `test` run and its 100%-coverage gate. A dedicated `test-wallet-cli-e2e` CI job (Node 20.x and 24.x) builds the dependency subtree and runs it; the per-package `test-*` matrix can't host it because it runs against source with no build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/lint-build-test.yml | 30 +++ packages/wallet-cli/jest.config.js | 16 +- .../src/daemon/lifecycle.daemon-e2e.test.ts | 233 ++++++++++++++++++ 3 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 packages/wallet-cli/src/daemon/lifecycle.daemon-e2e.test.ts diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 36039484255..f5630e1aae3 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -267,3 +267,33 @@ jobs: echo "Working tree dirty at end of job" exit 1 fi + + # The wallet-cli daemon e2e spawns the BUILT `mm` CLI and the native + # better-sqlite3 addon as real child processes, so it needs its dependency + # subtree built first and cannot run in the per-package `test-*` matrix above + # (which runs against source with no build). It has its own job here. Runs on + # the package's engine floor (20.x) and latest (24.x); better-sqlite3 needs + # Node >=20. + test-wallet-cli-e2e: + name: Test wallet-cli daemon e2e (${{ matrix.node-version }}) + runs-on: ubuntu-latest + needs: prepare + strategy: + matrix: + node-version: [20.x, 24.x] + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v2 + with: + is-high-risk-environment: false + node-version: ${{ matrix.node-version }} + - name: Build wallet-cli and its dependencies + run: yarn workspaces foreach --topological-dev --recursive --from '@metamask/wallet-cli' run build + - run: yarn workspace @metamask/wallet-cli run test:e2e + - name: Require clean working directory + shell: bash + run: | + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 + fi diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js index 568fedaec47..92deb2a26c3 100644 --- a/packages/wallet-cli/jest.config.js +++ b/packages/wallet-cli/jest.config.js @@ -21,8 +21,20 @@ module.exports = merge(baseConfig, { // 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/.*'], + // production code's test infrastructure, not production code itself. The + // daemon e2e is skipped from this run (below), so it never executes here and + // must also be excluded from coverage, or it would report as 0%-covered + // source. + coveragePathIgnorePatterns: [ + '.*/src/test/.*', + '.*\\.daemon-e2e\\.test\\.ts$', + ], + + // The subprocess daemon e2e (`*.daemon-e2e.test.ts`) spawns the built CLI and + // the native sqlite addon; it has its own config (`jest.config.e2e.js`, run + // via `yarn test:e2e`) and must not run in — or be coverage-gated by — the + // fast unit suite. + testPathIgnorePatterns: ['/node_modules/', '\\.daemon-e2e\\.test\\.ts$'], // The subprocess e2e suite lives in `tests/` and has its own config // (`jest.config.e2e.js`, run via `yarn test:e2e`); it spawns the built CLI diff --git a/packages/wallet-cli/src/daemon/lifecycle.daemon-e2e.test.ts b/packages/wallet-cli/src/daemon/lifecycle.daemon-e2e.test.ts new file mode 100644 index 00000000000..68af8b8d512 --- /dev/null +++ b/packages/wallet-cli/src/daemon/lifecycle.daemon-e2e.test.ts @@ -0,0 +1,233 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { getDaemonPaths } from './paths'; +import { isProcessAlive, readPidFile } from './utils'; + +// Subprocess-level lifecycle test for the `mm daemon` command suite. Unlike the +// in-process suites (`socket-integration.test.ts` exercises the transport in +// the test realm; `wallet-factory.e2e.test.ts` constructs a real `Wallet` +// in-process), this spawns the BUILT `mm` CLI as a child process against a temp +// data directory and drives the real `start → call → status/stop/purge` +// lifecycle over the Unix socket. It needs `dist/` and the native +// `better-sqlite3` addon, so it runs only via `yarn test:e2e` (its own jest +// config), excluded from the fast unit `test` run and its 100%-coverage gate. +// +// Offline-safe: the daemon's startup neither fetches feature flags +// (RemoteFeatureFlagController only fetches in `updateRemoteFeatureFlags`) nor +// looks up the network (NetworkController's `init` is synchronous), and the +// only action called here, `KeyringController:getState`, is local. + +// A valid 12-word BIP-39 mnemonic — the same fixtures the in-process e2e uses. +const TEST_SRP = 'test test test test test test test test test test test ball'; +const TEST_PASSWORD = 'testpass'; +// NetworkController requires a project ID but is never reached over the network +// here, so any well-formed-looking value works. +const TEST_INFURA_PROJECT_ID = '00000000000000000000000000000000'; + +const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/u; + +const BIN_PATH = join(__dirname, '..', '..', 'bin', 'run.mjs'); + +// Each step (spawn the CLI, construct a real Wallet, run PBKDF2 key derivation +// for the first-run SRP import) is slow; give the whole lifecycle room. +const STEP_TIMEOUT_MS = 60_000; + +type RunResult = { code: number | null; stdout: string; stderr: string }; + +/** + * Run the built `mm` CLI as a child process and capture its output. + * + * `NODE_OPTIONS` is stripped from the child environment so the parent jest + * run's `--experimental-vm-modules` flag does not leak an ExperimentalWarning + * onto the CLI's stderr. + * + * @param args - CLI arguments (e.g. `['daemon', 'start']`). + * @param dataDir - Data directory to point the CLI at (via `MM_DATA_DIR`). + * @returns The exit code and captured stdout/stderr. + */ +async function runMm(args: string[], dataDir: string): Promise { + const env = { ...process.env }; + delete env.NODE_OPTIONS; + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [BIN_PATH, ...args], { + env: { + ...env, + MM_DATA_DIR: dataDir, + INFURA_PROJECT_ID: TEST_INFURA_PROJECT_ID, + MM_WALLET_PASSWORD: TEST_PASSWORD, + MM_WALLET_SRP: TEST_SRP, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => (stdout += chunk.toString())); + child.stderr.on('data', (chunk) => (stderr += chunk.toString())); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +/** + * Call a messenger action on the running daemon and parse its JSON result. + * + * @param action - The messenger action name. + * @param dataDir - Data directory the daemon is using. + * @returns The parsed result object. + */ +async function callAction( + action: string, + dataDir: string, +): Promise> { + const result = await runMm(['daemon', 'call', action], dataDir); + expect(result.code).toBe(0); + return JSON.parse(result.stdout.trim()); +} + +/** + * Whether a path exists, without throwing on absence. + * + * @param path - The path to check. + * @returns True if `stat` succeeds. + */ +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +/** + * Guarantee no daemon is left running and remove the temp data directory, + * regardless of how a test ended. Kills by the recorded PID directly (rather + * than going through `mm daemon stop`) so a wedged daemon cannot block cleanup. + * + * @param dataDir - The temp data directory to tear down. + */ +async function cleanup(dataDir: string): Promise { + const { pidPath } = getDaemonPaths(dataDir); + const pid = await readPidFile(pidPath).catch(() => undefined); + if (pid !== undefined && isProcessAlive(pid)) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already gone — nothing to clean up. + } + } + await rm(dataDir, { recursive: true, force: true }); +} + +describe('mm daemon lifecycle (subprocess e2e)', () => { + let dataDir: string; + + beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), 'mm-e2e-')); + }); + + afterEach(async () => { + await cleanup(dataDir); + }); + + it( + 'starts, reports already-running, answers call & status, then stops', + async () => { + const start = await runMm(['daemon', 'start'], dataDir); + expect(start.code).toBe(0); + expect(start.stdout).toMatch(/Daemon running\. Socket:/u); + + // A second start finds the responsive daemon and leaves it untouched. + const restart = await runMm(['daemon', 'start'], dataDir); + expect(restart.code).toBe(0); + expect(restart.stdout).toMatch(/already running/iu); + + // First run imports the SRP, so the wallet is unlocked and exposes the + // derived account. + const keyringState = await callAction( + 'KeyringController:getState', + dataDir, + ); + expect(keyringState.isUnlocked).toBe(true); + const keyrings = keyringState.keyrings as { accounts: string[] }[]; + expect(keyrings[0]?.accounts[0]).toMatch(ADDRESS_REGEX); + + const status = await runMm(['daemon', 'status'], dataDir); + expect(status.code).toBe(0); + expect(status.stdout).toMatch( + /Daemon is running\. PID: \d+, Uptime: \d+s/u, + ); + + // The socket holds an unlocked wallet and the data dir holds the vault, so + // both must be owner-only (the only access-control boundary). The low 3 + // octal digits of `mode` are the permission bits. + const paths = getDaemonPaths(dataDir); + const socketMode = (await stat(paths.socketPath)).mode + .toString(8) + .slice(-3); + const dirMode = (await stat(dataDir)).mode.toString(8).slice(-3); + expect(socketMode).toBe('600'); + expect(dirMode).toBe('700'); + + const stop = await runMm(['daemon', 'stop'], dataDir); + expect(stop.code).toBe(0); + + const statusAfterStop = await runMm(['daemon', 'status'], dataDir); + expect(statusAfterStop.code).toBe(0); + expect(statusAfterStop.stdout).toMatch(/not running/iu); + }, + STEP_TIMEOUT_MS, + ); + + it( + 'resumes the persisted vault across a restart instead of re-importing', + async () => { + await runMm(['daemon', 'start'], dataDir); + const firstRun = await callAction('KeyringController:getState', dataDir); + expect(firstRun.isUnlocked).toBe(true); + const firstKeyrings = firstRun.keyrings as { accounts: string[] }[]; + expect(firstKeyrings[0]?.accounts[0]).toMatch(ADDRESS_REGEX); + + await runMm(['daemon', 'stop'], dataDir); + + // The on-disk database survives the stop. + const { dbPath } = getDaemonPaths(dataDir); + expect(await exists(dbPath)).toBe(true); + + await runMm(['daemon', 'start'], dataDir); + + // On the second start the persisted vault is found, so first-run SRP + // import is skipped and the wallet resumes LOCKED — a re-import would + // have left it unlocked. This is the observable signature of the + // `hasPersistedKeyring` resume path. + const resumed = await callAction('KeyringController:getState', dataDir); + expect(resumed.isUnlocked).toBe(false); + expect(typeof resumed.vault).toBe('string'); + + await runMm(['daemon', 'stop'], dataDir); + }, + STEP_TIMEOUT_MS, + ); + + it( + 'purges all daemon state', + async () => { + await runMm(['daemon', 'start'], dataDir); + + const purge = await runMm(['daemon', 'purge', '--force'], dataDir); + expect(purge.code).toBe(0); + expect(purge.stdout).toMatch(/All daemon state deleted/u); + + const paths = getDaemonPaths(dataDir); + expect(await exists(paths.dbPath)).toBe(false); + expect(await exists(paths.socketPath)).toBe(false); + expect(await exists(paths.pidPath)).toBe(false); + }, + STEP_TIMEOUT_MS, + ); +}); From a4aa800ed3d3c60f0aec90253f11c0777934539b Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 29 Jun 2026 22:55:34 +0200 Subject: [PATCH 02/13] move e2e tests --- .../{src/daemon => tests}/lifecycle.daemon-e2e.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename packages/wallet-cli/{src/daemon => tests}/lifecycle.daemon-e2e.test.ts (97%) diff --git a/packages/wallet-cli/src/daemon/lifecycle.daemon-e2e.test.ts b/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts similarity index 97% rename from packages/wallet-cli/src/daemon/lifecycle.daemon-e2e.test.ts rename to packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts index 68af8b8d512..3e34db1b6ac 100644 --- a/packages/wallet-cli/src/daemon/lifecycle.daemon-e2e.test.ts +++ b/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts @@ -3,8 +3,8 @@ import { mkdtemp, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { getDaemonPaths } from './paths'; -import { isProcessAlive, readPidFile } from './utils'; +import { getDaemonPaths } from '../src/daemon/paths'; +import { isProcessAlive, readPidFile } from '../src/daemon/utils'; // Subprocess-level lifecycle test for the `mm daemon` command suite. Unlike the // in-process suites (`socket-integration.test.ts` exercises the transport in @@ -29,7 +29,7 @@ const TEST_INFURA_PROJECT_ID = '00000000000000000000000000000000'; const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/u; -const BIN_PATH = join(__dirname, '..', '..', 'bin', 'run.mjs'); +const BIN_PATH = join(__dirname, '..', 'bin', 'run.mjs'); // Each step (spawn the CLI, construct a real Wallet, run PBKDF2 key derivation // for the first-run SRP import) is slow; give the whole lifecycle room. From f15e052bc07af9200d3101fa04cdd01fea6bfca3 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 30 Jun 2026 13:08:00 +0200 Subject: [PATCH 03/13] chore: simplify comment --- .github/workflows/lint-build-test.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index f5630e1aae3..33026991ecb 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -270,10 +270,7 @@ jobs: # The wallet-cli daemon e2e spawns the BUILT `mm` CLI and the native # better-sqlite3 addon as real child processes, so it needs its dependency - # subtree built first and cannot run in the per-package `test-*` matrix above - # (which runs against source with no build). It has its own job here. Runs on - # the package's engine floor (20.x) and latest (24.x); better-sqlite3 needs - # Node >=20. + # subtree built first and cannot run in the per-package `test-*` matrix above. test-wallet-cli-e2e: name: Test wallet-cli daemon e2e (${{ matrix.node-version }}) runs-on: ubuntu-latest From b73fc8221f16712ac292a51339204ad2fbb88cee Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 30 Jun 2026 13:28:30 +0200 Subject: [PATCH 04/13] fix: show logs and report dead daemon as stopped --- .../wallet-cli/src/daemon/stop-daemon.test.ts | 1 + .../tests/lifecycle.daemon-e2e.test.ts | 50 ++++++++++++++++--- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts index daaa96cd5cf..32d263c1e22 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.test.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -83,6 +83,7 @@ describe('stopDaemon', () => { expect(mockSendSignal).not.toHaveBeenCalled(); }); + it('signals the recorded PID when the socket is absent but the process is still alive', async () => { mockReadPidFile.mockResolvedValue(123); mockPingDaemon.mockResolvedValue(ABSENT); diff --git a/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts b/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts index 3e34db1b6ac..72adf8f5547 100644 --- a/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts +++ b/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -73,6 +73,40 @@ async function runMm(args: string[], dataDir: string): Promise { }); } +/** + * Assert that a CLI invocation exited 0. + * + * On failure this throws an error embedding the captured stdout/stderr AND the + * daemon's own log file. The harness otherwise discards all three when only the + * exit code is asserted, which leaves a CI-only failure (one we cannot + * reproduce locally) impossible to diagnose from the run output. The daemon log + * is the only window into the spawned process: it records `Shutting down + * (...)`, `handle.close() failed`, `Daemon fatal: ...`, etc. + * + * @param step - Human-readable label for the CLI step (e.g. `daemon stop`). + * @param result - The captured run result. + * @param dataDir - Data directory the daemon is using (to locate its log). + */ +async function expectSuccessfulRun( + step: string, + result: RunResult, + dataDir: string, +): Promise { + if (result.code === 0) { + return; + } + const { logPath } = getDaemonPaths(dataDir); + const daemonLog = await readFile(logPath, 'utf-8').catch( + (error: unknown) => ``, + ); + throw new Error( + `Expected \`mm ${step}\` to exit 0 but it exited ${String(result.code)}.\n` + + `=== stdout ===\n${result.stdout}\n` + + `=== stderr ===\n${result.stderr}\n` + + `=== ${logPath} ===\n${daemonLog}\n`, + ); +} + /** * Call a messenger action on the running daemon and parse its JSON result. * @@ -85,7 +119,7 @@ async function callAction( dataDir: string, ): Promise> { const result = await runMm(['daemon', 'call', action], dataDir); - expect(result.code).toBe(0); + await expectSuccessfulRun(`daemon call ${action}`, result, dataDir); return JSON.parse(result.stdout.trim()); } @@ -139,12 +173,12 @@ describe('mm daemon lifecycle (subprocess e2e)', () => { 'starts, reports already-running, answers call & status, then stops', async () => { const start = await runMm(['daemon', 'start'], dataDir); - expect(start.code).toBe(0); + await expectSuccessfulRun('daemon start', start, dataDir); expect(start.stdout).toMatch(/Daemon running\. Socket:/u); // A second start finds the responsive daemon and leaves it untouched. const restart = await runMm(['daemon', 'start'], dataDir); - expect(restart.code).toBe(0); + await expectSuccessfulRun('daemon start (restart)', restart, dataDir); expect(restart.stdout).toMatch(/already running/iu); // First run imports the SRP, so the wallet is unlocked and exposes the @@ -158,7 +192,7 @@ describe('mm daemon lifecycle (subprocess e2e)', () => { expect(keyrings[0]?.accounts[0]).toMatch(ADDRESS_REGEX); const status = await runMm(['daemon', 'status'], dataDir); - expect(status.code).toBe(0); + await expectSuccessfulRun('daemon status', status, dataDir); expect(status.stdout).toMatch( /Daemon is running\. PID: \d+, Uptime: \d+s/u, ); @@ -175,10 +209,10 @@ describe('mm daemon lifecycle (subprocess e2e)', () => { expect(dirMode).toBe('700'); const stop = await runMm(['daemon', 'stop'], dataDir); - expect(stop.code).toBe(0); + await expectSuccessfulRun('daemon stop', stop, dataDir); const statusAfterStop = await runMm(['daemon', 'status'], dataDir); - expect(statusAfterStop.code).toBe(0); + await expectSuccessfulRun('daemon status (after stop)', statusAfterStop, dataDir); expect(statusAfterStop.stdout).toMatch(/not running/iu); }, STEP_TIMEOUT_MS, @@ -220,7 +254,7 @@ describe('mm daemon lifecycle (subprocess e2e)', () => { await runMm(['daemon', 'start'], dataDir); const purge = await runMm(['daemon', 'purge', '--force'], dataDir); - expect(purge.code).toBe(0); + await expectSuccessfulRun('daemon purge --force', purge, dataDir); expect(purge.stdout).toMatch(/All daemon state deleted/u); const paths = getDaemonPaths(dataDir); From 8788bb1d457ed3f844c65b3f87e847096377ea27 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 30 Jun 2026 13:41:31 +0200 Subject: [PATCH 05/13] lint --- packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts b/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts index 72adf8f5547..f17aec07e9d 100644 --- a/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts +++ b/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts @@ -212,7 +212,11 @@ describe('mm daemon lifecycle (subprocess e2e)', () => { await expectSuccessfulRun('daemon stop', stop, dataDir); const statusAfterStop = await runMm(['daemon', 'status'], dataDir); - await expectSuccessfulRun('daemon status (after stop)', statusAfterStop, dataDir); + await expectSuccessfulRun( + 'daemon status (after stop)', + statusAfterStop, + dataDir, + ); expect(statusAfterStop.stdout).toMatch(/not running/iu); }, STEP_TIMEOUT_MS, From 5f20402d095ac2019d739bb6ad84b72488a58bfb Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 1 Jul 2026 11:20:18 +0200 Subject: [PATCH 06/13] refactor(wallet-cli): address daemon review feedback - Rename `fd` to `file descriptor` throughout the daemon spawn code and tests - Narrow `stopDaemon` stale-cleanup to `absent`/`refused` sockets only: a `permission`/`timeout`/`protocol` socket may be a wedged or foreign-user daemon, so it is no longer deleted or reported as a successful stop - Add a compile-time exhaustiveness guard to `ensureDaemon`'s ping handling so spawning is reachable only for a positive `absent` result - Replace the two mutable `{ value: T | null }` boxes with a single `StartupOutcome` discriminated union - Simplify/de-duplicate daemon comments; use `as unknown as ChildProcess` in the spawn mocks; add tests for the new branches Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-cli/src/daemon/stop-daemon.test.ts | 1 - packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts index 32d263c1e22..daaa96cd5cf 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.test.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -83,7 +83,6 @@ describe('stopDaemon', () => { expect(mockSendSignal).not.toHaveBeenCalled(); }); - it('signals the recorded PID when the socket is absent but the process is still alive', async () => { mockReadPidFile.mockResolvedValue(123); mockPingDaemon.mockResolvedValue(ABSENT); diff --git a/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts b/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts index f17aec07e9d..398afe574b7 100644 --- a/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts +++ b/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts @@ -77,11 +77,7 @@ async function runMm(args: string[], dataDir: string): Promise { * Assert that a CLI invocation exited 0. * * On failure this throws an error embedding the captured stdout/stderr AND the - * daemon's own log file. The harness otherwise discards all three when only the - * exit code is asserted, which leaves a CI-only failure (one we cannot - * reproduce locally) impossible to diagnose from the run output. The daemon log - * is the only window into the spawned process: it records `Shutting down - * (...)`, `handle.close() failed`, `Daemon fatal: ...`, etc. + * daemon's own log file. * * @param step - Human-readable label for the CLI step (e.g. `daemon stop`). * @param result - The captured run result. From 300a75708e5f14c3b764f72b62334e3f5fc8b2e8 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 1 Jul 2026 12:00:07 +0200 Subject: [PATCH 07/13] test(wallet-cli): key the subprocess e2e suite off the tests/ folder Generalize the e2e jest config to discriminate by folder instead of the feature-specific `daemon-e2e` filename suffix, so future subprocess e2e tests just drop into `tests/` with no config change. - jest.config.e2e.js: match `roots: ['/tests']` instead of `**/*.daemon-e2e.test.ts` - jest.config.js: ignore `/tests/`; drop the redundant daemon-e2e coverage exclusion (coverage is collected from `./src/**` only) - Rename lifecycle.daemon-e2e.test.ts -> lifecycle.e2e.test.ts - README: describe the suite by folder The in-process `wallet-factory.e2e.test.ts` stays in `src/` (unit suite): it needs the Web-Crypto polyfill env and is coverage-visible, unlike the subprocess suite. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-cli/jest.config.js | 21 +- .../tests/lifecycle.daemon-e2e.test.ts | 267 ------------------ 2 files changed, 7 insertions(+), 281 deletions(-) delete mode 100644 packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js index 92deb2a26c3..9239619d7f9 100644 --- a/packages/wallet-cli/jest.config.js +++ b/packages/wallet-cli/jest.config.js @@ -21,20 +21,13 @@ module.exports = merge(baseConfig, { // 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. The - // daemon e2e is skipped from this run (below), so it never executes here and - // must also be excluded from coverage, or it would report as 0%-covered - // source. - coveragePathIgnorePatterns: [ - '.*/src/test/.*', - '.*\\.daemon-e2e\\.test\\.ts$', - ], - - // The subprocess daemon e2e (`*.daemon-e2e.test.ts`) spawns the built CLI and - // the native sqlite addon; it has its own config (`jest.config.e2e.js`, run - // via `yarn test:e2e`) and must not run in — or be coverage-gated by — the - // fast unit suite. - testPathIgnorePatterns: ['/node_modules/', '\\.daemon-e2e\\.test\\.ts$'], + // production code's test infrastructure, not production code itself. + coveragePathIgnorePatterns: ['.*/src/test/.*'], + + // The subprocess e2e suite lives in `tests/` and has its own config + // (`jest.config.e2e.js`, run via `yarn test:e2e`); it spawns the built CLI + // and must not run in the fast unit suite. + testPathIgnorePatterns: ['/node_modules/', '/tests/'], // The subprocess e2e suite lives in `tests/` and has its own config // (`jest.config.e2e.js`, run via `yarn test:e2e`); it spawns the built CLI diff --git a/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts b/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts deleted file mode 100644 index 398afe574b7..00000000000 --- a/packages/wallet-cli/tests/lifecycle.daemon-e2e.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { spawn } from 'node:child_process'; -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { getDaemonPaths } from '../src/daemon/paths'; -import { isProcessAlive, readPidFile } from '../src/daemon/utils'; - -// Subprocess-level lifecycle test for the `mm daemon` command suite. Unlike the -// in-process suites (`socket-integration.test.ts` exercises the transport in -// the test realm; `wallet-factory.e2e.test.ts` constructs a real `Wallet` -// in-process), this spawns the BUILT `mm` CLI as a child process against a temp -// data directory and drives the real `start → call → status/stop/purge` -// lifecycle over the Unix socket. It needs `dist/` and the native -// `better-sqlite3` addon, so it runs only via `yarn test:e2e` (its own jest -// config), excluded from the fast unit `test` run and its 100%-coverage gate. -// -// Offline-safe: the daemon's startup neither fetches feature flags -// (RemoteFeatureFlagController only fetches in `updateRemoteFeatureFlags`) nor -// looks up the network (NetworkController's `init` is synchronous), and the -// only action called here, `KeyringController:getState`, is local. - -// A valid 12-word BIP-39 mnemonic — the same fixtures the in-process e2e uses. -const TEST_SRP = 'test test test test test test test test test test test ball'; -const TEST_PASSWORD = 'testpass'; -// NetworkController requires a project ID but is never reached over the network -// here, so any well-formed-looking value works. -const TEST_INFURA_PROJECT_ID = '00000000000000000000000000000000'; - -const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/u; - -const BIN_PATH = join(__dirname, '..', 'bin', 'run.mjs'); - -// Each step (spawn the CLI, construct a real Wallet, run PBKDF2 key derivation -// for the first-run SRP import) is slow; give the whole lifecycle room. -const STEP_TIMEOUT_MS = 60_000; - -type RunResult = { code: number | null; stdout: string; stderr: string }; - -/** - * Run the built `mm` CLI as a child process and capture its output. - * - * `NODE_OPTIONS` is stripped from the child environment so the parent jest - * run's `--experimental-vm-modules` flag does not leak an ExperimentalWarning - * onto the CLI's stderr. - * - * @param args - CLI arguments (e.g. `['daemon', 'start']`). - * @param dataDir - Data directory to point the CLI at (via `MM_DATA_DIR`). - * @returns The exit code and captured stdout/stderr. - */ -async function runMm(args: string[], dataDir: string): Promise { - const env = { ...process.env }; - delete env.NODE_OPTIONS; - - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [BIN_PATH, ...args], { - env: { - ...env, - MM_DATA_DIR: dataDir, - INFURA_PROJECT_ID: TEST_INFURA_PROJECT_ID, - MM_WALLET_PASSWORD: TEST_PASSWORD, - MM_WALLET_SRP: TEST_SRP, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => (stdout += chunk.toString())); - child.stderr.on('data', (chunk) => (stderr += chunk.toString())); - child.on('error', reject); - child.on('close', (code) => resolve({ code, stdout, stderr })); - }); -} - -/** - * Assert that a CLI invocation exited 0. - * - * On failure this throws an error embedding the captured stdout/stderr AND the - * daemon's own log file. - * - * @param step - Human-readable label for the CLI step (e.g. `daemon stop`). - * @param result - The captured run result. - * @param dataDir - Data directory the daemon is using (to locate its log). - */ -async function expectSuccessfulRun( - step: string, - result: RunResult, - dataDir: string, -): Promise { - if (result.code === 0) { - return; - } - const { logPath } = getDaemonPaths(dataDir); - const daemonLog = await readFile(logPath, 'utf-8').catch( - (error: unknown) => ``, - ); - throw new Error( - `Expected \`mm ${step}\` to exit 0 but it exited ${String(result.code)}.\n` + - `=== stdout ===\n${result.stdout}\n` + - `=== stderr ===\n${result.stderr}\n` + - `=== ${logPath} ===\n${daemonLog}\n`, - ); -} - -/** - * Call a messenger action on the running daemon and parse its JSON result. - * - * @param action - The messenger action name. - * @param dataDir - Data directory the daemon is using. - * @returns The parsed result object. - */ -async function callAction( - action: string, - dataDir: string, -): Promise> { - const result = await runMm(['daemon', 'call', action], dataDir); - await expectSuccessfulRun(`daemon call ${action}`, result, dataDir); - return JSON.parse(result.stdout.trim()); -} - -/** - * Whether a path exists, without throwing on absence. - * - * @param path - The path to check. - * @returns True if `stat` succeeds. - */ -async function exists(path: string): Promise { - try { - await stat(path); - return true; - } catch { - return false; - } -} - -/** - * Guarantee no daemon is left running and remove the temp data directory, - * regardless of how a test ended. Kills by the recorded PID directly (rather - * than going through `mm daemon stop`) so a wedged daemon cannot block cleanup. - * - * @param dataDir - The temp data directory to tear down. - */ -async function cleanup(dataDir: string): Promise { - const { pidPath } = getDaemonPaths(dataDir); - const pid = await readPidFile(pidPath).catch(() => undefined); - if (pid !== undefined && isProcessAlive(pid)) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // Already gone — nothing to clean up. - } - } - await rm(dataDir, { recursive: true, force: true }); -} - -describe('mm daemon lifecycle (subprocess e2e)', () => { - let dataDir: string; - - beforeEach(async () => { - dataDir = await mkdtemp(join(tmpdir(), 'mm-e2e-')); - }); - - afterEach(async () => { - await cleanup(dataDir); - }); - - it( - 'starts, reports already-running, answers call & status, then stops', - async () => { - const start = await runMm(['daemon', 'start'], dataDir); - await expectSuccessfulRun('daemon start', start, dataDir); - expect(start.stdout).toMatch(/Daemon running\. Socket:/u); - - // A second start finds the responsive daemon and leaves it untouched. - const restart = await runMm(['daemon', 'start'], dataDir); - await expectSuccessfulRun('daemon start (restart)', restart, dataDir); - expect(restart.stdout).toMatch(/already running/iu); - - // First run imports the SRP, so the wallet is unlocked and exposes the - // derived account. - const keyringState = await callAction( - 'KeyringController:getState', - dataDir, - ); - expect(keyringState.isUnlocked).toBe(true); - const keyrings = keyringState.keyrings as { accounts: string[] }[]; - expect(keyrings[0]?.accounts[0]).toMatch(ADDRESS_REGEX); - - const status = await runMm(['daemon', 'status'], dataDir); - await expectSuccessfulRun('daemon status', status, dataDir); - expect(status.stdout).toMatch( - /Daemon is running\. PID: \d+, Uptime: \d+s/u, - ); - - // The socket holds an unlocked wallet and the data dir holds the vault, so - // both must be owner-only (the only access-control boundary). The low 3 - // octal digits of `mode` are the permission bits. - const paths = getDaemonPaths(dataDir); - const socketMode = (await stat(paths.socketPath)).mode - .toString(8) - .slice(-3); - const dirMode = (await stat(dataDir)).mode.toString(8).slice(-3); - expect(socketMode).toBe('600'); - expect(dirMode).toBe('700'); - - const stop = await runMm(['daemon', 'stop'], dataDir); - await expectSuccessfulRun('daemon stop', stop, dataDir); - - const statusAfterStop = await runMm(['daemon', 'status'], dataDir); - await expectSuccessfulRun( - 'daemon status (after stop)', - statusAfterStop, - dataDir, - ); - expect(statusAfterStop.stdout).toMatch(/not running/iu); - }, - STEP_TIMEOUT_MS, - ); - - it( - 'resumes the persisted vault across a restart instead of re-importing', - async () => { - await runMm(['daemon', 'start'], dataDir); - const firstRun = await callAction('KeyringController:getState', dataDir); - expect(firstRun.isUnlocked).toBe(true); - const firstKeyrings = firstRun.keyrings as { accounts: string[] }[]; - expect(firstKeyrings[0]?.accounts[0]).toMatch(ADDRESS_REGEX); - - await runMm(['daemon', 'stop'], dataDir); - - // The on-disk database survives the stop. - const { dbPath } = getDaemonPaths(dataDir); - expect(await exists(dbPath)).toBe(true); - - await runMm(['daemon', 'start'], dataDir); - - // On the second start the persisted vault is found, so first-run SRP - // import is skipped and the wallet resumes LOCKED — a re-import would - // have left it unlocked. This is the observable signature of the - // `hasPersistedKeyring` resume path. - const resumed = await callAction('KeyringController:getState', dataDir); - expect(resumed.isUnlocked).toBe(false); - expect(typeof resumed.vault).toBe('string'); - - await runMm(['daemon', 'stop'], dataDir); - }, - STEP_TIMEOUT_MS, - ); - - it( - 'purges all daemon state', - async () => { - await runMm(['daemon', 'start'], dataDir); - - const purge = await runMm(['daemon', 'purge', '--force'], dataDir); - await expectSuccessfulRun('daemon purge --force', purge, dataDir); - expect(purge.stdout).toMatch(/All daemon state deleted/u); - - const paths = getDaemonPaths(dataDir); - expect(await exists(paths.dbPath)).toBe(false); - expect(await exists(paths.socketPath)).toBe(false); - expect(await exists(paths.pidPath)).toBe(false); - }, - STEP_TIMEOUT_MS, - ); -}); From 086df5c056d927e480a0e43d8cb088e236f19497 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 18 May 2026 21:21:35 +0200 Subject: [PATCH 08/13] refactor(wallet-cli): Parameterise RpcHandler with struct-validated dispatch Adds a `defineHandler(paramsStruct, run)` helper plus a generic `RpcHandler` / `RpcHandlerDefinition` type so each daemon RPC method owns its params struct. `rpc-socket-server` now validates params via `superstruct.validate` once per request and returns `-32602 invalidParams` on shape mismatch, so handler bodies can trust their input. Rewrites `getStatus` and `call` against the new shape and replaces the `wallet.messenger.call as any` cast with a single labelled `RpcDispatcher` bind. Tests updated; coverage stays at 100%. Closes #8777. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/wallet-cli/package.json | 1 + .../src/daemon/daemon-entry.test.ts | 71 ++++++++-------- .../wallet-cli/src/daemon/daemon-entry.ts | 81 ++++++++++++------- .../src/daemon/rpc-socket-server.test.ts | 71 +++++++++++++--- .../src/daemon/rpc-socket-server.ts | 19 ++++- .../src/daemon/socket-integration.test.ts | 33 ++++++-- packages/wallet-cli/src/daemon/types.ts | 63 +++++++++++++-- yarn.lock | 1 + 8 files changed, 257 insertions(+), 83 deletions(-) diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 93b9f587718..d45908f167f 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -49,6 +49,7 @@ "@metamask/remote-feature-flag-controller": "^4.2.2", "@metamask/rpc-errors": "^7.0.2", "@metamask/storage-service": "^1.0.2", + "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.11.0", "@metamask/wallet": "^7.0.1", "@oclif/core": "^4.10.5", diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index ce32f142ebc..398c29a4563 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -1,3 +1,4 @@ +import { validate } from '@metamask/superstruct'; import { appendFile, readFile, rm, writeFile } from 'node:fs/promises'; import { pingDaemon } from './daemon-client'; @@ -489,7 +490,7 @@ describe('daemon-entry', () => { const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; const { handlers } = callArgs; - const status = (await handlers.getStatus(null)) as { + const status = (await handlers.getStatus.run(null)) as { pid: number; uptime: number; }; @@ -512,7 +513,7 @@ describe('daemon-entry', () => { await importDaemonEntry(); const { handlers } = mockStartRpcSocketServer.mock.calls[0][0]; - const actions = await handlers.listActions(null); + const actions = await handlers.listActions.run(null); expect(actions).toStrictEqual([ 'NetworkController:getState', @@ -714,13 +715,18 @@ describe('daemon-entry', () => { describe('call handler', () => { /** - * Import the daemon entry and extract the `call` handler from the - * handlers map, along with the mock wallet for assertions. + * Import the daemon entry and extract the `call` handler definition from + * the handlers map, along with the mock wallet for assertions. * - * @returns The call handler function and mock wallet result. + * @returns The call handler definition and mock wallet result. */ async function setupCallHandler(): Promise<{ - callHandler: (params: unknown) => Promise; + callHandler: { + paramsStruct: import('@metamask/superstruct').Struct< + [string, ...unknown[]] + >; + run: (params: [string, ...unknown[]]) => Promise; + }; result: MockCreateWalletResult; }> { const result = createMockWallet(); @@ -730,20 +736,25 @@ describe('daemon-entry', () => { await importDaemonEntry(); const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; - const callHandler = callArgs.handlers.call as ( - params: unknown, - ) => Promise; + const callHandler = callArgs.handlers.call as unknown as { + paramsStruct: import('@metamask/superstruct').Struct< + [string, ...unknown[]] + >; + run: (params: [string, ...unknown[]]) => Promise; + }; return { callHandler, result }; } - it('registers a call handler', async () => { + it('registers a call handler definition', async () => { mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); await importDaemonEntry(); const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; - expect(typeof callArgs.handlers.call).toBe('function'); + const callDefinition = callArgs.handlers.call; + expect(callDefinition).toHaveProperty('paramsStruct'); + expect(typeof callDefinition.run).toBe('function'); }); it('forwards action and args to messenger.call', async () => { @@ -751,7 +762,7 @@ describe('daemon-entry', () => { const mockCall = result.wallet.messenger.call as jest.Mock; mockCall.mockReturnValue({ accounts: [] }); - const callResult = await callHandler([ + const callResult = await callHandler.run([ 'Controller:action', 'arg1', 'arg2', @@ -770,7 +781,7 @@ describe('daemon-entry', () => { const mockCall = result.wallet.messenger.call as jest.Mock; mockCall.mockReturnValue('ok'); - await callHandler(['Controller:action']); + await callHandler.run(['Controller:action']); expect(mockCall).toHaveBeenCalledWith('Controller:action'); }); @@ -780,7 +791,7 @@ describe('daemon-entry', () => { const mockCall = result.wallet.messenger.call as jest.Mock; mockCall.mockResolvedValue({ async: true }); - const callResult = await callHandler(['Controller:asyncAction']); + const callResult = await callHandler.run(['Controller:asyncAction']); expect(callResult).toStrictEqual({ async: true }); }); @@ -792,33 +803,29 @@ describe('daemon-entry', () => { throw new Error('A handler for Unknown:action has not been registered'); }); - await expect(callHandler(['Unknown:action'])).rejects.toThrow( + await expect(callHandler.run(['Unknown:action'])).rejects.toThrow( 'A handler for Unknown:action has not been registered', ); }); - it('throws when params is null', async () => { + it.each([ + ['null', null], + ['empty array', []], + ['non-string first element', [42]], + ['non-array', { foo: 'bar' }], + ])('paramsStruct rejects invalid params (%s)', async (_label, value) => { 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', - ); + const [error] = validate(value, callHandler.paramsStruct); + expect(error).toBeDefined(); }); - it('throws when action name is not a string', async () => { + it('paramsStruct accepts a non-empty array starting with a string', async () => { const { callHandler } = await setupCallHandler(); - - await expect(callHandler([42])).rejects.toThrow( - 'Expected params to be an array with an action name', + const [error] = validate( + ['Controller:action', 1, 'two'], + callHandler.paramsStruct, ); + expect(error).toBeUndefined(); }); }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index a4b8f89d928..d8053b7427e 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -1,3 +1,4 @@ +import { define, literal } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import type { Wallet } from '@metamask/wallet'; import { appendFile, readFile, rm, writeFile } from 'node:fs/promises'; @@ -7,10 +8,34 @@ import { ensureOwnerOnlyDirectory } from './data-dir'; import { getDaemonPaths } from './paths'; import { startRpcSocketServer } from './rpc-socket-server'; import type { RpcSocketServerHandle } from './rpc-socket-server'; -import type { DaemonStatusInfo, Logger, RpcHandlerMap } from './types'; +import { defineHandler } from './types'; +import type { + DaemonStatusInfo, + Logger, + RpcDispatcher, + RpcHandlerMap, +} from './types'; import { isErrorWithCode, isProcessAlive, readPidFile } from './utils'; import { createWallet } from './wallet-factory'; +/** + * Params struct for the `call` RPC method. `params` must be a non-empty array + * whose first element is the messenger action name; remaining elements are + * positional action arguments forwarded as-is to `messenger.call`. + */ +const callParamsStruct = define<[string, ...Json[]]>('CallParams', (value) => { + if (!Array.isArray(value)) { + return 'Expected an array'; + } + if (value.length === 0) { + return 'Expected a non-empty array'; + } + if (typeof value[0] !== 'string') { + return 'Expected the first element to be a string action name'; + } + return true; +}); + const startTime = Date.now(); main().catch((error: unknown) => { @@ -98,39 +123,37 @@ async function main(): Promise { })); const constructedWallet = wallet; + // 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 is chmodded to 0o700 above and the socket to + // 0o600 by the RPC server on bind, so only the owning user can open them, + // but there is no in-process auth check beyond that filesystem-permission + // barrier. The messenger is strongly typed by action name; we narrow it + // once here to the RpcDispatcher shape the `call` handler needs. + const dispatch = constructedWallet.messenger.call.bind( + constructedWallet.messenger, + ) as unknown as RpcDispatcher; + const handlers: RpcHandlerMap = { - getStatus: async (): Promise => ({ - pid: process.pid, - uptime: Math.floor((Date.now() - startTime) / 1000), + getStatus: defineHandler( + literal(null), + async (): Promise => ({ + pid: process.pid, + uptime: Math.floor((Date.now() - startTime) / 1000), + }), + ), + call: defineHandler(callParamsStruct, async (params) => { + const [action, ...args] = params; + return await dispatch(action, ...args); }), // Exposes the callable surface for discovery: it grows silently as // controllers are wired, so consumers need a way to see it without a // hand-kept catalog that would rot. - listActions: async (): Promise => - constructedWallet.messenger.getRegisteredActionTypes(), - // 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 is chmodded to 0o700 above and the socket to - // 0o600 by the RPC server on bind, 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[]]; - // The messenger's `call` is typed to a literal action-name union; the - // daemon dispatches arbitrary action names from RPC. Cast to a - // string-keyed `call` (which preserves arity) rather than to `any`, so - // the only untyped value is the `unknown` result narrowed below. - type ArbitraryDispatch = { - call: (actionName: string, ...callArgs: Json[]) => unknown; - }; - const result = ( - constructedWallet.messenger as unknown as ArbitraryDispatch - ).call(action, ...args); - return (result instanceof Promise ? await result : result) as Json; - }, + listActions: defineHandler( + literal(null), + async (): Promise => + constructedWallet.messenger.getRegisteredActionTypes(), + ), }; // `startRpcSocketServer` restricts the socket to the owner (chmod 0o600) 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 150819e17c1..a00fb421a8c 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -1,10 +1,26 @@ +import { any, literal } from '@metamask/superstruct'; import { EventEmitter } from 'node:events'; import { chmod, 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'; +import type { RpcHandlerDefinition, RpcHandlerMap } from './types'; + +/** + * Wrap a `jest.fn` as an {@link RpcHandlerDefinition} so existing tests can + * register a handler without writing out the `{ paramsStruct, run }` shape. + * Defaults to `any()` so the struct guard never rejects the test inputs. + * + * @param run - The mocked handler function. + * @returns A handler definition with an `any()` paramsStruct. + */ +function asHandler(run: jest.Mock): RpcHandlerDefinition { + return { + paramsStruct: any(), + run: run as unknown as RpcHandlerDefinition['run'], + }; +} jest.mock('node:fs/promises'); jest.mock('node:net'); @@ -224,7 +240,7 @@ describe('startRpcSocketServer', () => { it('dispatches valid request to handler and returns result', async () => { const { simulateConnection } = createMockServer(); const handlers: RpcHandlerMap = { - getStatus: jest.fn().mockResolvedValue({ status: 'ok' }), + getStatus: asHandler(jest.fn().mockResolvedValue({ status: 'ok' })), }; await startRpcSocketServer({ @@ -252,7 +268,7 @@ describe('startRpcSocketServer', () => { it('returns null result when handler returns undefined', async () => { const { simulateConnection } = createMockServer(); const handlers: RpcHandlerMap = { - noop: jest.fn().mockResolvedValue(undefined), + noop: asHandler(jest.fn().mockResolvedValue(undefined)), }; await startRpcSocketServer({ @@ -367,7 +383,9 @@ describe('startRpcSocketServer', () => { it('returns -32603 when handler throws an Error', async () => { const { simulateConnection } = createMockServer(); const handlers: RpcHandlerMap = { - failing: jest.fn().mockRejectedValue(new Error('handler failed')), + failing: asHandler( + jest.fn().mockRejectedValue(new Error('handler failed')), + ), }; await startRpcSocketServer({ @@ -393,7 +411,7 @@ describe('startRpcSocketServer', () => { const { simulateConnection } = createMockServer(); const rpcError = { code: -32001, message: 'custom rpc' }; const handlers: RpcHandlerMap = { - failing: jest.fn().mockRejectedValue(rpcError), + failing: asHandler(jest.fn().mockRejectedValue(rpcError)), }; await startRpcSocketServer({ @@ -416,7 +434,7 @@ describe('startRpcSocketServer', () => { it('returns Internal error when handler throws a non-Error value', async () => { const { simulateConnection } = createMockServer(); const handlers: RpcHandlerMap = { - failing: jest.fn().mockRejectedValue('string error'), + failing: asHandler(jest.fn().mockRejectedValue('string error')), }; await startRpcSocketServer({ @@ -540,7 +558,7 @@ describe('startRpcSocketServer', () => { it('accumulates partial data across multiple events', async () => { const { simulateConnection } = createMockServer(); const handlers: RpcHandlerMap = { - test: jest.fn().mockResolvedValue('ok'), + test: asHandler(jest.fn().mockResolvedValue('ok')), }; await startRpcSocketServer({ @@ -619,7 +637,7 @@ describe('startRpcSocketServer', () => { const circular: Record = {}; circular.self = circular; const handlers: RpcHandlerMap = { - bad: jest.fn().mockResolvedValue(circular), + bad: asHandler(jest.fn().mockResolvedValue(circular)), }; await startRpcSocketServer({ @@ -691,10 +709,45 @@ describe('startRpcSocketServer', () => { jest.useRealTimers(); }); + it('returns -32602 when params fail the registered struct', async () => { + const { simulateConnection } = createMockServer(); + const run = jest.fn(); + const handlers: RpcHandlerMap = { + strict: { + paramsStruct: literal('expected'), + run: run as unknown as RpcHandlerMap[string]['run'], + }, + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { + jsonrpc: '2.0', + id: '1', + method: 'strict', + params: ['something else'], + }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32602, + message: expect.stringContaining('Invalid params for strict'), + }), + ); + expect(run).not.toHaveBeenCalled(); + }); + 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 }), + failing: asHandler(jest.fn().mockRejectedValue({ code: 42 })), }; 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 89170be5fef..a221235e150 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -1,4 +1,5 @@ import { rpcErrors } from '@metamask/rpc-errors'; +import { validate as validateStruct } from '@metamask/superstruct'; import type { JsonRpcId, JsonRpcParams, @@ -229,7 +230,23 @@ async function handleRequest( }; } - const result = await handler(coerceHandlerParams(params)); + const [structError, validatedParams] = validateStruct( + coerceHandlerParams(params), + handler.paramsStruct, + ); + if (structError !== undefined) { + return { + jsonrpc: '2.0', + id, + error: rpcErrors + .invalidParams({ + message: `Invalid params for ${method}: ${structError.message}`, + }) + .serialize(), + }; + } + + const result = await handler.run(validatedParams); return { jsonrpc: '2.0', id, result: result ?? null }; } catch (error) { log(`RPC handler "${method}" failed: ${String(error)}`); diff --git a/packages/wallet-cli/src/daemon/socket-integration.test.ts b/packages/wallet-cli/src/daemon/socket-integration.test.ts index c06bbe008de..25daaea2eda 100644 --- a/packages/wallet-cli/src/daemon/socket-integration.test.ts +++ b/packages/wallet-cli/src/daemon/socket-integration.test.ts @@ -1,3 +1,4 @@ +import { any } from '@metamask/superstruct'; import { stat } from 'node:fs/promises'; import { createConnection } from 'node:net'; import { tmpdir } from 'node:os'; @@ -6,6 +7,24 @@ import { join } from 'node:path'; import { pingDaemon, sendCommand } from './daemon-client'; import { startRpcSocketServer } from './rpc-socket-server'; import type { RpcSocketServerHandle } from './rpc-socket-server'; +import type { RpcHandlerDefinition } from './types'; + +/** + * Wrap a plain async function as an {@link RpcHandlerDefinition} with an + * `any()` paramsStruct so integration tests don't need to spell out the full + * definition shape for each handler. + * + * @param run - The async handler implementation. + * @returns A handler definition usable in an {@link RpcHandlerMap}. + */ +function handlerDefinition( + run: (params: unknown) => Promise, +): RpcHandlerDefinition { + return { + paramsStruct: any(), + run: run as unknown as RpcHandlerDefinition['run'], + }; +} /** * End-to-end integration tests for the daemon's IPC layer: real @@ -54,7 +73,7 @@ describe('socket integration', () => { await startServer({ socketPath, handlers: { - getStatus: async () => ({ pid: 42, uptime: 7 }), + getStatus: handlerDefinition(async () => ({ pid: 42, uptime: 7 })), }, }); @@ -88,7 +107,7 @@ describe('socket integration', () => { await startServer({ socketPath, handlers: { - getStatus: async () => ({ pid: 1, uptime: 0 }), + getStatus: handlerDefinition(async () => ({ pid: 1, uptime: 0 })), }, }); @@ -105,9 +124,9 @@ describe('socket integration', () => { await startServer({ socketPath, handlers: { - boom: async () => { + boom: handlerDefinition(async () => { throw new Error('handler exploded'); - }, + }), }, }); @@ -130,7 +149,7 @@ describe('socket integration', () => { await startServer({ socketPath, handlers: { - getStatus: async () => ({ pid: 1, uptime: 0 }), + getStatus: handlerDefinition(async () => ({ pid: 1, uptime: 0 })), }, }); @@ -150,7 +169,7 @@ describe('socket integration', () => { await startServer({ socketPath, handlers: { - echo: async (params) => ({ params }), + echo: handlerDefinition(async (params) => ({ params })), }, }); @@ -199,7 +218,7 @@ describe('socket integration', () => { await startServer({ socketPath, handlers: { - getStatus: async () => ({ pid: 1, uptime: 0 }), + getStatus: handlerDefinition(async () => ({ pid: 1, uptime: 0 })), }, }); diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index eddedc8b336..30dcd152acf 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -1,3 +1,4 @@ +import type { Struct } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; /** @@ -7,16 +8,68 @@ import type { Json } from '@metamask/utils'; export type Logger = (message: string) => void; /** - * A function that handles a JSON-RPC method call. + * A function that handles a JSON-RPC method call after its params have been + * validated by the corresponding {@link RpcHandlerDefinition.paramsStruct}. + */ +export type RpcHandler = ( + params: TParams, +) => Promise; + +/** + * Definition for a single JSON-RPC method: the struct that validates + * incoming `params` plus the handler that runs once `params` is known to + * match. * - * The `params` argument will be `null` if the client did not provide params. + * The server (see `rpc-socket-server.ts`) validates the raw `params` against + * `paramsStruct` before invoking `run`, so each handler body can trust the + * shape of its input without re-checking. */ -export type RpcHandler = (params: Json) => Promise; +export type RpcHandlerDefinition = { + paramsStruct: Struct; + run: RpcHandler; +}; /** - * A map of RPC method names to their handler functions. + * A map of RPC method names to their handler definitions. + * + * `TParams` is widened to `any` here so definitions with different narrow + * params types (e.g. `null` vs. a tuple) can coexist in the same map. The + * runtime struct guard validates `params` before each `run` invocation, so the + * widening cannot let an unvalidated value reach a handler body. + */ +export type RpcHandlerMap = Record< + string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + RpcHandlerDefinition +>; + +/** + * Bundle a params struct with the handler that runs once `params` is + * validated. The server invokes `run` only after `paramsStruct` accepts the + * value, so `run` can trust the type of its argument. + * + * @param paramsStruct - Struct that validates `params` for this method. + * @param run - Handler invoked with the validated params. + * @returns An {@link RpcHandlerDefinition} suitable for an {@link RpcHandlerMap}. + */ +export function defineHandler( + paramsStruct: Struct, + run: RpcHandler, +): RpcHandlerDefinition { + return { paramsStruct, run }; +} + +/** + * Typed wrapper around `wallet.messenger.call` used by the `call` RPC. + * + * The messenger is strongly typed by action name; the daemon exposes the full + * messenger surface over the socket and dispatches by string, so we narrow it + * to a single, documented escape hatch instead of casting at each call site. */ -export type RpcHandlerMap = Record; +export type RpcDispatcher = ( + action: string, + ...args: Json[] +) => Json | Promise; /** * Resolved paths for daemon state files. diff --git a/yarn.lock b/yarn.lock index c439f3e2cf8..eec9edfd6ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9046,6 +9046,7 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/storage-service": "npm:^1.0.2" + "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@metamask/wallet": "npm:^7.0.1" "@oclif/core": "npm:^4.10.5" From 1b0daabbc9a963def16464a13d890cdd9d2fe57d Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 2 Jul 2026 11:56:30 +0200 Subject: [PATCH 09/13] chore: fix rebase artefacts after rebasing onto main Remove duplicate `test-wallet-cli-e2e` CI job (stale v2 entry merged in), duplicate `testPathIgnorePatterns` key in jest.config.js, and stale eslint-suppressions entry removed by lint:fix. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/lint-build-test.yml | 27 --------------------------- packages/wallet-cli/jest.config.js | 5 ----- 2 files changed, 32 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 33026991ecb..36039484255 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -267,30 +267,3 @@ jobs: echo "Working tree dirty at end of job" exit 1 fi - - # The wallet-cli daemon e2e spawns the BUILT `mm` CLI and the native - # better-sqlite3 addon as real child processes, so it needs its dependency - # subtree built first and cannot run in the per-package `test-*` matrix above. - test-wallet-cli-e2e: - name: Test wallet-cli daemon e2e (${{ matrix.node-version }}) - runs-on: ubuntu-latest - needs: prepare - strategy: - matrix: - node-version: [20.x, 24.x] - steps: - - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v2 - with: - is-high-risk-environment: false - node-version: ${{ matrix.node-version }} - - name: Build wallet-cli and its dependencies - run: yarn workspaces foreach --topological-dev --recursive --from '@metamask/wallet-cli' run build - - run: yarn workspace @metamask/wallet-cli run test:e2e - - name: Require clean working directory - shell: bash - run: | - if ! git diff --exit-code; then - echo "Working tree dirty at end of job" - exit 1 - fi diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js index 9239619d7f9..568fedaec47 100644 --- a/packages/wallet-cli/jest.config.js +++ b/packages/wallet-cli/jest.config.js @@ -29,11 +29,6 @@ module.exports = merge(baseConfig, { // and must not run in the fast unit suite. testPathIgnorePatterns: ['/node_modules/', '/tests/'], - // The subprocess e2e suite lives in `tests/` and has its own config - // (`jest.config.e2e.js`, run via `yarn test:e2e`); it spawns the built CLI - // and must not run in the fast unit suite. - testPathIgnorePatterns: ['/node_modules/', '/tests/'], - // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { From 66ac5db4d19347a1f4fb032d0b3d54e059afbedb Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 2 Jul 2026 11:58:16 +0200 Subject: [PATCH 10/13] chore: restore eslint-suppressions.json to main state Co-Authored-By: Claude Sonnet 4.6 --- eslint-suppressions.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 46131ea3fee..79caefd17d3 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2240,6 +2240,11 @@ "count": 2 } }, + "packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts": { "no-restricted-syntax": { "count": 1 From c961cbaaaef164a748cc39bafcbe703890c056d6 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 2 Jul 2026 12:11:57 +0200 Subject: [PATCH 11/13] fix(wallet-cli): address review feedback on RPC handler structs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard activeDispose() in shutdown closure with try-catch + log; a throwing dispose during SIGTERM/SIGINT previously skipped PID/socket cleanup silently - Change callParamsStruct type annotation from Json[] to unknown[] for the tail elements, matching what the runtime validator actually checks - Simplify RpcDispatcher return type to Promise (always awaited) - Fix "narrow" → "consolidate the unsafe cast to" in RpcDispatcher JSDoc - Fix missed handlerDefinition() migration in socket-integration test - Reduce asHandler/handlerDefinition JSDoc blocks to single inline comments - Add tests: getStatus/listActions struct rejection, params-absent → -32602, log-on-handler-throw, dispose-error-during-shutdown Co-Authored-By: Claude Sonnet 4.6 --- .../src/daemon/daemon-entry.test.ts | 42 ++++++++++++ .../wallet-cli/src/daemon/daemon-entry.ts | 10 ++- .../src/daemon/rpc-socket-server.test.ts | 65 ++++++++++++++++--- .../src/daemon/socket-integration.test.ts | 11 +--- packages/wallet-cli/src/daemon/types.ts | 7 +- 5 files changed, 112 insertions(+), 23 deletions(-) diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 398c29a4563..ecaa2b7a6b6 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -521,6 +521,28 @@ describe('daemon-entry', () => { ]); }); + it('getStatus paramsStruct rejects non-null params', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const { handlers } = mockStartRpcSocketServer.mock.calls[0][0]; + const [error] = validate(['unexpected'], handlers.getStatus.paramsStruct); + expect(error).toBeDefined(); + }); + + it('listActions paramsStruct rejects non-null params', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const { handlers } = mockStartRpcSocketServer.mock.calls[0][0]; + const [error] = validate(['unexpected'], handlers.listActions.paramsStruct); + expect(error).toBeDefined(); + }); + it('logs to file via makeLogger', async () => { mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); @@ -626,6 +648,26 @@ describe('daemon-entry', () => { ); }); + it('logs dispose error during shutdown without aborting cleanup', async () => { + const result = createMockWallet(); + (result.dispose as jest.Mock).mockRejectedValue(new Error('dispose 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(handle.close).toHaveBeenCalled(); + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('dispose() failed during shutdown'), + ); + }); + it('handles rm rejection during shutdown cleanup gracefully', async () => { const result = createMockWallet(); mockCreateWallet.mockResolvedValue(result); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index d8053b7427e..9482376942a 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -23,7 +23,7 @@ import { createWallet } from './wallet-factory'; * whose first element is the messenger action name; remaining elements are * positional action arguments forwarded as-is to `messenger.call`. */ -const callParamsStruct = define<[string, ...Json[]]>('CallParams', (value) => { +const callParamsStruct = define<[string, ...unknown[]]>('CallParams', (value) => { if (!Array.isArray(value)) { return 'Expected an array'; } @@ -144,7 +144,7 @@ async function main(): Promise { ), call: defineHandler(callParamsStruct, async (params) => { const [action, ...args] = params; - return await dispatch(action, ...args); + return await dispatch(action, ...(args as Json[])); }), // Exposes the callable surface for discovery: it grows silently as // controllers are wired, so consumers need a way to see it without a @@ -205,7 +205,11 @@ async function main(): Promise { } catch (closeError) { log(`handle.close() failed: ${String(closeError)}`); } - await activeDispose(); + try { + await activeDispose(); + } catch (disposeError) { + log(`dispose() failed during shutdown: ${String(disposeError)}`); + } await Promise.all([ removeOwnedPidFile(pidPath, pidFileContents).catch( (rmError: unknown) => { 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 a00fb421a8c..f46a96963fd 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -7,14 +7,7 @@ import type { Server, Socket } from 'node:net'; import { startRpcSocketServer } from './rpc-socket-server'; import type { RpcHandlerDefinition, RpcHandlerMap } from './types'; -/** - * Wrap a `jest.fn` as an {@link RpcHandlerDefinition} so existing tests can - * register a handler without writing out the `{ paramsStruct, run }` shape. - * Defaults to `any()` so the struct guard never rejects the test inputs. - * - * @param run - The mocked handler function. - * @returns A handler definition with an `any()` paramsStruct. - */ +// any() paramsStruct so the struct guard never rejects test inputs. function asHandler(run: jest.Mock): RpcHandlerDefinition { return { paramsStruct: any(), @@ -744,6 +737,62 @@ describe('startRpcSocketServer', () => { expect(run).not.toHaveBeenCalled(); }); + it('returns -32602 when params are absent and struct rejects null', async () => { + const { simulateConnection } = createMockServer(); + const run = jest.fn(); + const handlers: RpcHandlerMap = { + strict: { + paramsStruct: literal('expected'), + run: run as unknown as RpcHandlerMap[string]['run'], + }, + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'strict' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32602, + message: expect.stringContaining('Invalid params for strict'), + }), + ); + expect(run).not.toHaveBeenCalled(); + }); + + it('logs the method name when a handler throws', async () => { + const { simulateConnection } = createMockServer(); + const log = jest.fn(); + const handlers: RpcHandlerMap = { + failing: asHandler( + jest.fn().mockRejectedValue(new Error('handler failed')), + ), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + log, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining('failing'), + ); + }); + it('wraps thrown object with code but no message as internal error', async () => { const { simulateConnection } = createMockServer(); const handlers: RpcHandlerMap = { diff --git a/packages/wallet-cli/src/daemon/socket-integration.test.ts b/packages/wallet-cli/src/daemon/socket-integration.test.ts index 25daaea2eda..98c619f2ded 100644 --- a/packages/wallet-cli/src/daemon/socket-integration.test.ts +++ b/packages/wallet-cli/src/daemon/socket-integration.test.ts @@ -9,14 +9,7 @@ import { startRpcSocketServer } from './rpc-socket-server'; import type { RpcSocketServerHandle } from './rpc-socket-server'; import type { RpcHandlerDefinition } from './types'; -/** - * Wrap a plain async function as an {@link RpcHandlerDefinition} with an - * `any()` paramsStruct so integration tests don't need to spell out the full - * definition shape for each handler. - * - * @param run - The async handler implementation. - * @returns A handler definition usable in an {@link RpcHandlerMap}. - */ +// any() paramsStruct so integration test inputs are never rejected by the struct guard. function handlerDefinition( run: (params: unknown) => Promise, ): RpcHandlerDefinition { @@ -93,7 +86,7 @@ describe('socket integration', () => { await startServer({ socketPath, handlers: { - getStatus: async () => ({ pid: 1, uptime: 0 }), + getStatus: handlerDefinition(async () => ({ pid: 1, uptime: 0 })), }, }); diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index 30dcd152acf..127a08e894c 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -63,13 +63,14 @@ export function defineHandler( * Typed wrapper around `wallet.messenger.call` used by the `call` RPC. * * The messenger is strongly typed by action name; the daemon exposes the full - * messenger surface over the socket and dispatches by string, so we narrow it - * to a single, documented escape hatch instead of casting at each call site. + * messenger surface over the socket and dispatches by string, so we consolidate + * the unsafe cast into a single, documented escape hatch instead of repeating + * it at each call site. */ export type RpcDispatcher = ( action: string, ...args: Json[] -) => Json | Promise; +) => Promise; /** * Resolved paths for daemon state files. From 99e87a9be839d4cba77116c5ab431b7036d205da Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 2 Jul 2026 12:20:32 +0200 Subject: [PATCH 12/13] chore(wallet-cli): fix prettier formatting Co-Authored-By: Claude Sonnet 4.6 --- .../src/daemon/daemon-entry.test.ts | 4 ++- .../wallet-cli/src/daemon/daemon-entry.ts | 27 ++++++++++--------- .../src/daemon/rpc-socket-server.test.ts | 4 +-- packages/wallet-cli/src/daemon/types.ts | 5 +--- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index ecaa2b7a6b6..72528b26040 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -650,7 +650,9 @@ describe('daemon-entry', () => { it('logs dispose error during shutdown without aborting cleanup', async () => { const result = createMockWallet(); - (result.dispose as jest.Mock).mockRejectedValue(new Error('dispose failed')); + (result.dispose as jest.Mock).mockRejectedValue( + new Error('dispose failed'), + ); mockCreateWallet.mockResolvedValue(result); 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 9482376942a..34c89f87dcd 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -23,18 +23,21 @@ import { createWallet } from './wallet-factory'; * whose first element is the messenger action name; remaining elements are * positional action arguments forwarded as-is to `messenger.call`. */ -const callParamsStruct = define<[string, ...unknown[]]>('CallParams', (value) => { - if (!Array.isArray(value)) { - return 'Expected an array'; - } - if (value.length === 0) { - return 'Expected a non-empty array'; - } - if (typeof value[0] !== 'string') { - return 'Expected the first element to be a string action name'; - } - return true; -}); +const callParamsStruct = define<[string, ...unknown[]]>( + 'CallParams', + (value) => { + if (!Array.isArray(value)) { + return 'Expected an array'; + } + if (value.length === 0) { + return 'Expected a non-empty array'; + } + if (typeof value[0] !== 'string') { + return 'Expected the first element to be a string action name'; + } + return true; + }, +); const startTime = Date.now(); 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 f46a96963fd..aeea4e8d236 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -788,9 +788,7 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect(log).toHaveBeenCalledWith( - expect.stringContaining('failing'), - ); + expect(log).toHaveBeenCalledWith(expect.stringContaining('failing')); }); it('wraps thrown object with code but no message as internal error', async () => { diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index 127a08e894c..1a8924aa10d 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -67,10 +67,7 @@ export function defineHandler( * the unsafe cast into a single, documented escape hatch instead of repeating * it at each call site. */ -export type RpcDispatcher = ( - action: string, - ...args: Json[] -) => Promise; +export type RpcDispatcher = (action: string, ...args: Json[]) => Promise; /** * Resolved paths for daemon state files. From 0c971a5f3c7dde52b1e463c45233301023ab2b80 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 14 Jul 2026 15:42:06 +0200 Subject: [PATCH 13/13] =?UTF-8?q?fix(wallet-cli):=20address=20review=20fee?= =?UTF-8?q?dback=20=E2=80=94=20use=20unknown=20instead=20of=20any=20in=20R?= =?UTF-8?q?pcHandlerMap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- packages/wallet-cli/CHANGELOG.md | 1 + packages/wallet-cli/src/daemon/types.ts | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index e33b8e09ec8..d8295d12e18 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The daemon RPC server now validates `params` against each handler's superstruct before dispatch, returning a `-32602 invalidParams` error on mismatch instead of passing raw params to the handler ([#8846](https://github.com/MetaMask/core/pull/8846)) - Report daemon socket connection errors consistently across `mm daemon call` and `mm daemon list` ([#9339](https://github.com/MetaMask/core/pull/9339)) - Bump `@metamask/wallet` from `^3.0.0` to `^7.0.1` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9263](https://github.com/MetaMask/core/pull/9263), [#9349](https://github.com/MetaMask/core/pull/9349), [#9396](https://github.com/MetaMask/core/pull/9396), [#9470](https://github.com/MetaMask/core/pull/9470)) diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index 1a8924aa10d..0f46714535f 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -32,15 +32,17 @@ export type RpcHandlerDefinition = { /** * A map of RPC method names to their handler definitions. * - * `TParams` is widened to `any` here so definitions with different narrow - * params types (e.g. `null` vs. a tuple) can coexist in the same map. The - * runtime struct guard validates `params` before each `run` invocation, so the - * widening cannot let an unvalidated value reach a handler body. + * `TParams` is erased to `unknown` here so definitions with different narrow + * params types (e.g. `null` vs. a tuple) can coexist in the same map. Consumers + * therefore see each `run` as accepting `unknown` and must validate `params` + * against the paired `paramsStruct` before invoking it — which is exactly what + * the server (see `rpc-socket-server.ts`) does. The concrete `TParams` is + * captured inside {@link defineHandler}, where the struct and handler are bound + * together. */ export type RpcHandlerMap = Record< string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - RpcHandlerDefinition + RpcHandlerDefinition >; /** @@ -48,6 +50,9 @@ export type RpcHandlerMap = Record< * validated. The server invokes `run` only after `paramsStruct` accepts the * value, so `run` can trust the type of its argument. * + * The returned definition erases `TParams` to `unknown` so heterogeneous + * handlers can share an {@link RpcHandlerMap}. + * * @param paramsStruct - Struct that validates `params` for this method. * @param run - Handler invoked with the validated params. * @returns An {@link RpcHandlerDefinition} suitable for an {@link RpcHandlerMap}. @@ -55,8 +60,11 @@ export type RpcHandlerMap = Record< export function defineHandler( paramsStruct: Struct, run: RpcHandler, -): RpcHandlerDefinition { - return { paramsStruct, run }; +): RpcHandlerDefinition { + return { paramsStruct, run } as unknown as RpcHandlerDefinition< + unknown, + TResult + >; } /**