@@ -1979,7 +1979,7 @@ describe("CLI e2e happy path", () => {
19791979 tempoRpcCallCount = 0 ; // reset for fresh faucet flow
19801980 const { run } = await import ( "./cli/lib/init.mjs" ) ;
19811981 captureStart ( ) ;
1982- await run ( [ "mpp" ] ) ;
1982+ await run ( [ "mpp" , "--switch-rail" ] ) ;
19831983 captureStop ( ) ;
19841984 const out = captured ( ) ;
19851985 assert . ok ( out . includes ( "Tempo" ) , "should show Tempo network" ) ;
@@ -1998,7 +1998,7 @@ describe("CLI e2e happy path", () => {
19981998 tempoRpcCallCount = 0 ; // first eth_call returns 0 → triggers faucet path
19991999 const { run } = await import ( "./cli/lib/init.mjs" ) ;
20002000 captureStart ( ) ;
2001- await run ( [ "mpp" , "--json" ] ) ;
2001+ await run ( [ "mpp" , "--json" , "--switch-rail" ] ) ;
20022002 captureStop ( ) ;
20032003 const stdout = capturedStdout ( ) ;
20042004 const parsed = JSON . parse ( stdout ) ;
@@ -2042,7 +2042,7 @@ describe("CLI e2e happy path", () => {
20422042 it ( "init (switch back to x402)" , async ( ) => {
20432043 const { run } = await import ( "./cli/lib/init.mjs" ) ;
20442044 captureStart ( ) ;
2045- await run ( [ ] ) ;
2045+ await run ( [ "--switch-rail" ] ) ;
20462046 captureStop ( ) ;
20472047 const out = captured ( ) ;
20482048 assert . ok ( out . includes ( "Base Sepolia" ) , "should show Base Sepolia network" ) ;
@@ -2704,3 +2704,144 @@ describe("CLI destructive delete --confirm guard (GH-212)", () => {
27042704 assert . ok ( del , `must issue DELETE /domains/v1/example.com, calls: ${ JSON . stringify ( calls ) } ` ) ;
27052705 } ) ;
27062706} ) ;
2707+
2708+ // ── init <rail> --switch-rail guard (GH-210) ────────────────────────────────
2709+ // `run402 init mpp` (or `init` with x402 default) must NOT silently switch the
2710+ // persisted payment rail when the existing allowance is on the other rail.
2711+ // Switching is destructive in the sense that it changes which network the
2712+ // agent's autonomous payments will land on; it must be explicit.
2713+
2714+ describe ( "CLI init rail-switch guard (GH-210)" , ( ) => {
2715+ async function seedAllowance ( rail ) {
2716+ const { saveAllowance } = await import ( "./cli/lib/config.mjs" ) ;
2717+ saveAllowance ( {
2718+ address : "0x1234567890123456789012345678901234567890" ,
2719+ privateKey : "0x" + "11" . repeat ( 32 ) ,
2720+ created : "2026-01-01T00:00:00.000Z" ,
2721+ funded : true ,
2722+ rail,
2723+ } ) ;
2724+ }
2725+
2726+ async function clearAllowance ( ) {
2727+ const { ALLOWANCE_FILE } = await import ( "./cli/lib/config.mjs" ) ;
2728+ try { rmSync ( ALLOWANCE_FILE , { force : true } ) ; } catch { }
2729+ }
2730+
2731+ it ( "init mpp (no flag) on x402 allowance refuses and leaves rail unchanged" , async ( ) => {
2732+ await seedAllowance ( "x402" ) ;
2733+ const { ALLOWANCE_FILE } = await import ( "./cli/lib/config.mjs" ) ;
2734+ const before = JSON . parse ( readFileSync ( ALLOWANCE_FILE , "utf8" ) ) ;
2735+ const { run } = await import ( "./cli/lib/init.mjs" ) ;
2736+ let threw = null ;
2737+ captureStart ( ) ;
2738+ try {
2739+ await run ( [ "mpp" ] ) ;
2740+ } catch ( e ) { threw = e ; } finally {
2741+ captureStop ( ) ;
2742+ }
2743+ assert . equal ( threw ?. message , "process.exit(1)" , "must exit non-zero" ) ;
2744+ const stderr = capturedStderr ( ) ;
2745+ const line = stderr . split ( "\n" ) . find ( ( s ) => s . trim ( ) . startsWith ( "{" ) ) ;
2746+ assert . ok ( line , `expected JSON envelope on stderr, got: ${ stderr } ` ) ;
2747+ const parsed = JSON . parse ( line ) ;
2748+ assert . equal ( parsed . status , "error" ) ;
2749+ assert . equal ( parsed . code , "RAIL_SWITCH_REQUIRES_CONFIRM" ) ;
2750+ assert . ok ( / - - s w i t c h - r a i l / . test ( parsed . message ) , `message should mention --switch-rail, got: ${ parsed . message } ` ) ;
2751+ assert . equal ( parsed . details ?. current_rail , "x402" ) ;
2752+ assert . equal ( parsed . details ?. requested_rail , "mpp" ) ;
2753+ const after = JSON . parse ( readFileSync ( ALLOWANCE_FILE , "utf8" ) ) ;
2754+ assert . equal ( after . rail , before . rail , "allowance.rail must NOT change without --switch-rail" ) ;
2755+ assert . equal ( after . address , before . address , "allowance.address must not change" ) ;
2756+ } ) ;
2757+
2758+ it ( "init mpp --switch-rail on x402 allowance proceeds and updates rail" , async ( ) => {
2759+ await seedAllowance ( "x402" ) ;
2760+ const { run } = await import ( "./cli/lib/init.mjs" ) ;
2761+ let threw = null ;
2762+ captureStart ( ) ;
2763+ try {
2764+ await run ( [ "mpp" , "--switch-rail" ] ) ;
2765+ } catch ( e ) { threw = e ; } finally {
2766+ captureStop ( ) ;
2767+ }
2768+ assert . equal ( threw , null , `should succeed, got: ${ threw ?. message || "" } / ${ capturedStderr ( ) } ` ) ;
2769+ const { ALLOWANCE_FILE } = await import ( "./cli/lib/config.mjs" ) ;
2770+ const after = JSON . parse ( readFileSync ( ALLOWANCE_FILE , "utf8" ) ) ;
2771+ assert . equal ( after . rail , "mpp" , "rail should be updated to mpp" ) ;
2772+ const out = captured ( ) ;
2773+ assert . ok ( / S w i t c h e d f r o m x 4 0 2 / . test ( out ) , `should retain "Switched from x402" UX note, got: ${ out } ` ) ;
2774+ } ) ;
2775+
2776+ it ( "init x402 on x402 allowance is idempotent (no flag needed)" , async ( ) => {
2777+ await seedAllowance ( "x402" ) ;
2778+ const { run } = await import ( "./cli/lib/init.mjs" ) ;
2779+ let threw = null ;
2780+ captureStart ( ) ;
2781+ try {
2782+ await run ( [ ] ) ;
2783+ } catch ( e ) { threw = e ; } finally {
2784+ captureStop ( ) ;
2785+ }
2786+ assert . equal ( threw , null , `same-rail re-run should succeed, got: ${ threw ?. message || "" } / ${ capturedStderr ( ) } ` ) ;
2787+ const { ALLOWANCE_FILE } = await import ( "./cli/lib/config.mjs" ) ;
2788+ const after = JSON . parse ( readFileSync ( ALLOWANCE_FILE , "utf8" ) ) ;
2789+ assert . equal ( after . rail , "x402" , "rail should remain x402" ) ;
2790+ } ) ;
2791+
2792+ it ( "init mpp on mpp allowance is idempotent (no flag needed)" , async ( ) => {
2793+ await seedAllowance ( "mpp" ) ;
2794+ const { run } = await import ( "./cli/lib/init.mjs" ) ;
2795+ let threw = null ;
2796+ captureStart ( ) ;
2797+ try {
2798+ await run ( [ "mpp" ] ) ;
2799+ } catch ( e ) { threw = e ; } finally {
2800+ captureStop ( ) ;
2801+ }
2802+ assert . equal ( threw , null , `same-rail re-run should succeed, got: ${ threw ?. message || "" } / ${ capturedStderr ( ) } ` ) ;
2803+ const { ALLOWANCE_FILE } = await import ( "./cli/lib/config.mjs" ) ;
2804+ const after = JSON . parse ( readFileSync ( ALLOWANCE_FILE , "utf8" ) ) ;
2805+ assert . equal ( after . rail , "mpp" , "rail should remain mpp" ) ;
2806+ } ) ;
2807+
2808+ it ( "init mpp with no existing allowance succeeds (no rail to switch from)" , async ( ) => {
2809+ await clearAllowance ( ) ;
2810+ const { run } = await import ( "./cli/lib/init.mjs" ) ;
2811+ let threw = null ;
2812+ captureStart ( ) ;
2813+ try {
2814+ await run ( [ "mpp" ] ) ;
2815+ } catch ( e ) { threw = e ; } finally {
2816+ captureStop ( ) ;
2817+ }
2818+ assert . equal ( threw , null , `fresh init should succeed, got: ${ threw ?. message || "" } / ${ capturedStderr ( ) } ` ) ;
2819+ const { ALLOWANCE_FILE } = await import ( "./cli/lib/config.mjs" ) ;
2820+ const after = JSON . parse ( readFileSync ( ALLOWANCE_FILE , "utf8" ) ) ;
2821+ assert . equal ( after . rail , "mpp" , "fresh allowance should be created with rail=mpp" ) ;
2822+ } ) ;
2823+
2824+ it ( "init x402 (default) on mpp allowance refuses and leaves rail unchanged" , async ( ) => {
2825+ await seedAllowance ( "mpp" ) ;
2826+ const { ALLOWANCE_FILE } = await import ( "./cli/lib/config.mjs" ) ;
2827+ const before = JSON . parse ( readFileSync ( ALLOWANCE_FILE , "utf8" ) ) ;
2828+ const { run } = await import ( "./cli/lib/init.mjs" ) ;
2829+ let threw = null ;
2830+ captureStart ( ) ;
2831+ try {
2832+ await run ( [ ] ) ;
2833+ } catch ( e ) { threw = e ; } finally {
2834+ captureStop ( ) ;
2835+ }
2836+ assert . equal ( threw ?. message , "process.exit(1)" , "must exit non-zero" ) ;
2837+ const stderr = capturedStderr ( ) ;
2838+ const line = stderr . split ( "\n" ) . find ( ( s ) => s . trim ( ) . startsWith ( "{" ) ) ;
2839+ assert . ok ( line , `expected JSON envelope on stderr, got: ${ stderr } ` ) ;
2840+ const parsed = JSON . parse ( line ) ;
2841+ assert . equal ( parsed . code , "RAIL_SWITCH_REQUIRES_CONFIRM" ) ;
2842+ assert . equal ( parsed . details ?. current_rail , "mpp" ) ;
2843+ assert . equal ( parsed . details ?. requested_rail , "x402" ) ;
2844+ const after = JSON . parse ( readFileSync ( ALLOWANCE_FILE , "utf8" ) ) ;
2845+ assert . equal ( after . rail , before . rail , "allowance.rail must NOT change without --switch-rail" ) ;
2846+ } ) ;
2847+ } ) ;
0 commit comments