@@ -60,9 +60,11 @@ function connectAndAuth(token: string): Promise<WebSocket> {
6060function waitForClose ( ws : WebSocket ) : Promise < number > {
6161 return new Promise ( ( resolve ) => {
6262 // Drop connectAndAuth's rejecting close/error listeners — from here on
63- // a close is the expected outcome, not a failure.
63+ // a close is the expected outcome, not a failure. Keep a no-op error
64+ // listener: an 'error' with no listener throws on EventEmitters.
6465 ws . removeAllListeners ( 'close' ) ;
6566 ws . removeAllListeners ( 'error' ) ;
67+ ws . on ( 'error' , ( ) => { } ) ;
6668 ws . on ( 'close' , ( code ) => resolve ( code ) ) ;
6769 } ) ;
6870}
@@ -108,6 +110,31 @@ describe('mobile token over WebSocket', () => {
108110 } ) ;
109111} ) ;
110112
113+ describe ( 'unauthenticated WebSocket clients' , ( ) => {
114+ function connectRaw ( ) : Promise < WebSocket > {
115+ return new Promise ( ( resolve , reject ) => {
116+ const ws = new WebSocket ( `ws://127.0.0.1:${ port } /ws` ) ;
117+ ws . on ( 'open' , ( ) => resolve ( ws ) ) ;
118+ ws . on ( 'error' , reject ) ;
119+ } ) ;
120+ }
121+
122+ it ( 'closes 4001 when input is sent before auth, without reaching the PTY' , async ( ) => {
123+ const ws = await connectRaw ( ) ;
124+ const closed = waitForClose ( ws ) ;
125+ ws . send ( JSON . stringify ( { type : 'input' , agentId : 'agent-1' , data : 'hi' } ) ) ;
126+ expect ( await closed ) . toBe ( 4001 ) ;
127+ expect ( pty . writeToAgent ) . not . toHaveBeenCalled ( ) ;
128+ } ) ;
129+
130+ it ( 'closes 4001 on auth with an unknown token' , async ( ) => {
131+ const ws = await connectRaw ( ) ;
132+ const closed = waitForClose ( ws ) ;
133+ ws . send ( JSON . stringify ( { type : 'auth' , token : 'not-a-real-token' } ) ) ;
134+ expect ( await closed ) . toBe ( 4001 ) ;
135+ } ) ;
136+ } ) ;
137+
111138describe ( 'coordinator token over WebSocket' , ( ) => {
112139 it ( 'forwards input to the agent PTY' , async ( ) => {
113140 const ws = await connectAndAuth ( coordinatorToken ) ;
0 commit comments