@@ -97,6 +97,14 @@ describe("devices cli approve", () => {
9797 ts : 1000 ,
9898 } ,
9999 ] ,
100+ paired : [
101+ {
102+ deviceId : "device-9" ,
103+ displayName : "Device Nine" ,
104+ roles : [ "operator" ] ,
105+ scopes : [ "operator.read" ] ,
106+ } ,
107+ ] ,
100108 } ) ;
101109
102110 await runDevicesApprove ( [ ] ) ;
@@ -108,6 +116,8 @@ describe("devices cli approve", () => {
108116 const logOutput = runtime . log . mock . calls . map ( ( c ) => readRuntimeCallText ( c ) ) . join ( "\n" ) ;
109117 expect ( logOutput ) . toContain ( "req-abc" ) ;
110118 expect ( logOutput ) . toContain ( "Device Nine" ) ;
119+ expect ( logOutput ) . toContain ( "Approved: roles: operator; scopes: operator.read" ) ;
120+ expect ( logOutput ) . toContain ( "Requested scopes exceed the current approval" ) ;
111121 expect ( runtime . error ) . toHaveBeenCalledWith (
112122 expect . stringContaining ( "openclaw devices approve req-abc" ) ,
113123 ) ;
@@ -117,6 +127,36 @@ describe("devices cli approve", () => {
117127 ) ;
118128 } ) ;
119129
130+ it ( "sanitizes preview ip output for implicit approval" , async ( ) => {
131+ callGateway . mockResolvedValueOnce ( {
132+ pending : [
133+ {
134+ requestId : "req-abc" ,
135+ deviceId : "device-9" ,
136+ displayName : "Device Nine" ,
137+ role : "operator" ,
138+ scopes : [ "operator.admin" ] ,
139+ remoteIp : "10.0.0.9\rspoof" ,
140+ ts : 1000 ,
141+ } ,
142+ ] ,
143+ paired : [
144+ {
145+ deviceId : "device-9" ,
146+ displayName : "Device Nine" ,
147+ roles : [ "operator" ] ,
148+ scopes : [ "operator.read" ] ,
149+ } ,
150+ ] ,
151+ } ) ;
152+
153+ await runDevicesApprove ( [ ] ) ;
154+
155+ const logOutput = runtime . log . mock . calls . map ( ( c ) => readRuntimeCallText ( c ) ) . join ( "\n" ) ;
156+ expect ( logOutput ) . not . toContain ( "\r" ) ;
157+ expect ( logOutput ) . toContain ( "IP: 10.0.0.9spoof" ) ;
158+ } ) ;
159+
120160 it . each ( [
121161 {
122162 name : "id is omitted" ,
@@ -208,6 +248,7 @@ describe("devices cli approve", () => {
208248 it ( "returns JSON for implicit approval preview in JSON mode" , async ( ) => {
209249 callGateway . mockResolvedValueOnce ( {
210250 pending : [ { requestId : "req-json" , deviceId : "device-json" , ts : 1000 } ] ,
251+ paired : [ ] ,
211252 } ) ;
212253
213254 await runDevicesApprove ( [ "--latest" , "--json" , "--url" , "ws://gateway.example:18789" ] ) ;
@@ -216,6 +257,11 @@ describe("devices cli approve", () => {
216257 expect ( runtime . error ) . not . toHaveBeenCalled ( ) ;
217258 expect ( runtime . writeJson ) . toHaveBeenCalledWith ( {
218259 selected : { requestId : "req-json" , deviceId : "device-json" , ts : 1000 } ,
260+ approvalState : {
261+ kind : "new-pairing" ,
262+ requested : { roles : [ ] , scopes : [ ] } ,
263+ approved : null ,
264+ } ,
219265 approveCommand : "openclaw devices approve req-json --url ws://gateway.example:18789 --json" ,
220266 requiresAuthFlags : {
221267 token : false ,
@@ -404,7 +450,7 @@ describe("devices cli local fallback", () => {
404450} ) ;
405451
406452describe ( "devices cli list" , ( ) => {
407- it ( "renders pending scopes when present " , async ( ) => {
453+ it ( "renders requested versus approved access for pending upgrades " , async ( ) => {
408454 callGateway . mockResolvedValueOnce ( {
409455 pending : [
410456 {
@@ -416,14 +462,119 @@ describe("devices cli list", () => {
416462 ts : 1 ,
417463 } ,
418464 ] ,
419- paired : [ ] ,
465+ paired : [
466+ {
467+ deviceId : "device-1" ,
468+ displayName : "Device One" ,
469+ roles : [ "operator" ] ,
470+ scopes : [ "operator.read" ] ,
471+ } ,
472+ ] ,
473+ } ) ;
474+
475+ await runDevicesCommand ( [ "list" ] ) ;
476+
477+ const output = runtime . log . mock . calls . map ( ( entry ) => readRuntimeCallText ( entry ) ) . join ( "\n" ) ;
478+ expect ( output ) . toContain ( "Requested" ) ;
479+ expect ( output ) . toContain ( "Approved" ) ;
480+ expect ( output ) . toContain ( "operator.write" ) ;
481+ expect ( output ) . toContain ( "operator.read" ) ;
482+ expect ( output ) . toContain ( "scope upgrade" ) ;
483+ } ) ;
484+
485+ it ( "normalizes pending device ids before matching paired approvals" , async ( ) => {
486+ callGateway . mockResolvedValueOnce ( {
487+ pending : [
488+ {
489+ requestId : "req-1" ,
490+ deviceId : " device-1 " ,
491+ displayName : "Device One" ,
492+ role : "operator" ,
493+ scopes : [ "operator.admin" ] ,
494+ ts : 1 ,
495+ } ,
496+ ] ,
497+ paired : [
498+ {
499+ deviceId : "device-1" ,
500+ displayName : "Device One" ,
501+ roles : [ "operator" ] ,
502+ scopes : [ "operator.read" ] ,
503+ } ,
504+ ] ,
505+ } ) ;
506+
507+ await runDevicesCommand ( [ "list" ] ) ;
508+
509+ const output = runtime . log . mock . calls . map ( ( entry ) => readRuntimeCallText ( entry ) ) . join ( "\n" ) ;
510+ expect ( output ) . toContain ( "scope upgrade" ) ;
511+ expect ( output ) . toContain ( "operator.read" ) ;
512+ } ) ;
513+
514+ it ( "does not show upgrade context for key-mismatched pending requests" , async ( ) => {
515+ callGateway . mockResolvedValueOnce ( {
516+ pending : [
517+ {
518+ requestId : "req-1" ,
519+ deviceId : "device-1" ,
520+ publicKey : "new-key" ,
521+ displayName : "Device One" ,
522+ role : "operator" ,
523+ scopes : [ "operator.admin" ] ,
524+ ts : 1 ,
525+ } ,
526+ ] ,
527+ paired : [
528+ {
529+ deviceId : "device-1" ,
530+ publicKey : "old-key" ,
531+ displayName : "Device One" ,
532+ roles : [ "operator" ] ,
533+ scopes : [ "operator.read" ] ,
534+ } ,
535+ ] ,
536+ } ) ;
537+
538+ await runDevicesCommand ( [ "list" ] ) ;
539+
540+ const output = runtime . log . mock . calls . map ( ( entry ) => readRuntimeCallText ( entry ) ) . join ( "\n" ) ;
541+ expect ( output ) . toContain ( "new pairing" ) ;
542+ expect ( output ) . not . toContain ( "scope upgrade" ) ;
543+ expect ( output ) . not . toContain ( "roles: operator; scopes: operator.read" ) ;
544+ } ) ;
545+
546+ it ( "sanitizes device-controlled terminal output" , async ( ) => {
547+ callGateway . mockResolvedValueOnce ( {
548+ pending : [
549+ {
550+ requestId : "req-1" ,
551+ deviceId : "device-1" ,
552+ displayName : "Bad\u001b[2J\nName" ,
553+ role : "operator" ,
554+ scopes : [ "operator.admin" ] ,
555+ remoteIp : "10.0.0.9\rspoof" ,
556+ ts : 1 ,
557+ } ,
558+ ] ,
559+ paired : [
560+ {
561+ deviceId : "device-1" ,
562+ displayName : "Pair\u001b]8;;https://evil.example\u001b\\ed" ,
563+ roles : [ "operator" ] ,
564+ scopes : [ "operator.read" ] ,
565+ remoteIp : "10.0.0.1\u007f" ,
566+ } ,
567+ ] ,
420568 } ) ;
421569
422570 await runDevicesCommand ( [ "list" ] ) ;
423571
424572 const output = runtime . log . mock . calls . map ( ( entry ) => readRuntimeCallText ( entry ) ) . join ( "\n" ) ;
425- expect ( output ) . toContain ( "Scopes" ) ;
426- expect ( output ) . toContain ( "operator.admin, operator.read" ) ;
573+ expect ( output ) . not . toContain ( "\u001b" ) ;
574+ expect ( output ) . not . toContain ( "\r" ) ;
575+ expect ( output ) . toContain ( "BadName" ) ;
576+ expect ( output ) . toContain ( "spoof" ) ;
577+ expect ( output ) . toContain ( "Paired" ) ;
427578 } ) ;
428579} ) ;
429580
0 commit comments