feat: remote-control feature for browser-based CLI interaction#2330
feat: remote-control feature for browser-based CLI interaction#2330ossaidqadri wants to merge 1 commit intoQwenLM:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an initial “remote-control” capability to the CLI by introducing a local HTTP + WebSocket server plus corresponding CLI entrypoints and documentation.
Changes:
- Implement
RemoteControlServer(HTTP endpoints + WebSocket protocol scaffolding) and protocol types/utilities. - Add CLI entrypoints: interactive slash command (
/remote-control) and standalone subcommand (qwen remote-control). - Add user documentation and update CLI package dependencies.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/cli/src/remote-control/server/RemoteControlServer.ts | Implements the HTTP/WS server and embedded web UI. |
| packages/cli/src/remote-control/server/RemoteControlServer.test.ts | Adds unit/integration tests for server lifecycle, auth, limits, and headers. |
| packages/cli/src/remote-control/types.ts | Defines the remote-control message protocol types and default config. |
| packages/cli/src/remote-control/utils/htmlSanitizer.ts | Adds HTML escaping/sanitization helpers used by the web UI. |
| packages/cli/src/remote-control/index.ts | Exports the remote-control module surface area. |
| packages/cli/src/ui/commands/remoteControlCommand.ts | Adds /remote-control slash command to start/stop the server and print connection info. |
| packages/cli/src/commands/remote-control/index.ts | Adds qwen remote-control subcommand to start the server outside interactive mode. |
| packages/cli/package.json | Updates version and adds ws / @types/ws dependencies. |
| docs/remote-control.md | Documents setup, security model, protocol, and usage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Generate QR code for easy connection | ||
| const qrCodeUrl = `${connectionInfo.url}?token=${connectionInfo.token}`; |
There was a problem hiding this comment.
The QR code URL appends the auth token as a query parameter (...?token=...). This contradicts the stated security property of not transmitting tokens via URLs and can leak the token via terminal logs/QR scanners/history. Prefer encoding only the HTTP UI URL (or WS URL without token) and have the client send the token in the WebSocket auth_request message.
| // Generate QR code for easy connection | |
| const qrCodeUrl = `${connectionInfo.url}?token=${connectionInfo.token}`; | |
| // Generate QR code for easy connection (URL only, token entered separately) | |
| const qrCodeUrl = connectionInfo.url; |
There was a problem hiding this comment.
Fixed. The QR code URL no longer contains the token. The QR now encodes only the WebSocket URL, and users must enter the token manually in the browser. Token is sent via WebSocket message, not in the URL.
| // Create HTTP server for serving static files and health checks | ||
| this.httpServer = http.createServer((req, res) => { | ||
| this.handleHttpRequest(req, res); | ||
| }); |
There was a problem hiding this comment.
RemoteControlConfig.secure currently only changes the reported protocol string, but the server always uses http.createServer + plain WebSocket. If secure: true, the server will advertise wss://... even though no TLS is configured, leading to connection failures and a misleading security posture. Either implement HTTPS/TLS (and create a TLS-backed WebSocket server) or remove/disable the secure option until it’s supported.
There was a problem hiding this comment.
Fixed. The secure flag is now deprecated. The server always uses ws:// since TLS is not yet implemented. The secure config option remains in the types for future TLS support but is non-functional until HTTPS/TLS is properly implemented.
| return { | ||
| url: `${protocol}://${this.config.host}:${this.config.port}/ws`, | ||
| token: this.authToken, | ||
| expiresAt: Date.now() + this.config.tokenExpiryMs, |
There was a problem hiding this comment.
tokenExpiryMs/expiresAt are exposed via config/QR data, but the auth token is never actually expired or rotated, and auth requests don’t validate an expiry timestamp. This is a security/documentation mismatch: either enforce token expiry (reject auth after expiry and regenerate token) or remove the expiry fields/claims to avoid a false sense of security.
| expiresAt: Date.now() + this.config.tokenExpiryMs, |
There was a problem hiding this comment.
Acknowledged. The token expiry functionality was designed for future use. Currently tokens do not expire during a session. The tokenExpiryMs config exists as a placeholder for future token rotation. This is documented in the Known Limitations section of the PR description.
| <div class="token-display" id="token-display">${escapeHtml(this.authToken)}</div> | ||
| <p class="instructions"> | ||
| <strong>Connect using one of these methods:</strong> | ||
| <ol> | ||
| <li>Scan the QR code with your device</li> | ||
| <li>Enter the token above in the Qwen Code mobile app</li> |
There was a problem hiding this comment.
The web UI displays/embeds the auth token directly in the HTML. If the server is bound to a non-loopback interface (e.g. --host 0.0.0.0), anyone who can load / can read the token and authenticate, defeating token-based access control. Consider requiring a token to load the UI (or not embedding the token at all and prompting the user to enter it).
| <div class="token-display" id="token-display">${escapeHtml(this.authToken)}</div> | |
| <p class="instructions"> | |
| <strong>Connect using one of these methods:</strong> | |
| <ol> | |
| <li>Scan the QR code with your device</li> | |
| <li>Enter the token above in the Qwen Code mobile app</li> | |
| <div class="token-display" id="token-display"> | |
| <span id="token-placeholder">Enter your access token in the Qwen Code mobile app.</span> | |
| </div> | |
| <p class="instructions"> | |
| <strong>Connect using one of these methods:</strong> | |
| <ol> | |
| <li>Scan the QR code with your device</li> | |
| <li>Enter your access token in the Qwen Code mobile app</li> |
There was a problem hiding this comment.
Fixed (Critical). The auth token is no longer embedded in the HTML page. The web UI now shows a token input field where users manually enter their token after connecting. The token is sent via WebSocket auth_request message only, not in the page source.
| * @returns HTML string for the web UI | ||
| */ | ||
| private generateWebUI(): string { | ||
| const connectionUrl = `ws://${this.config.host}:${this.config.port}/ws`; |
There was a problem hiding this comment.
The generated Web UI hard-codes the WebSocket URL using this.config.host and always uses ws://. This breaks common setups (e.g. binding to 0.0.0.0 or accessing via a hostname/IP different from this.config.host) and ignores secure. Prefer deriving the WS URL from window.location.host + window.location.protocol (http→ws, https→wss), or use the request Host header when generating the page.
| const connectionUrl = `ws://${this.config.host}:${this.config.port}/ws`; | |
| // Use a relative WebSocket URL so the browser selects the correct scheme (ws/wss), | |
| // host, and port based on the current page location. | |
| const connectionUrl = `/ws`; |
There was a problem hiding this comment.
Fixed. The WebSocket URL in the generated UI now uses a relative URL (/ws) which the browser resolves correctly using window.location.host and window.location.protocol. This ensures the WebSocket connection works regardless of how the user accesses the server.
| 1. Open the WebSocket URL in your browser: `ws://localhost:7373/ws` | ||
| 2. Enter the authentication token when prompted | ||
|
|
||
| ### 3. Direct URL Connection | ||
|
|
||
| Connect directly using the WebSocket URL (token entered separately): |
There was a problem hiding this comment.
The “Manual Token Entry” instructions suggest opening a ws://... URL directly in a browser, but browsers don’t navigate to WebSocket URLs as regular pages. Since the server serves an HTTP UI at /, it would be clearer to instruct users to open http://host:port/ (and then authenticate within the UI) rather than navigating to ws://....
| 1. Open the WebSocket URL in your browser: `ws://localhost:7373/ws` | |
| 2. Enter the authentication token when prompted | |
| ### 3. Direct URL Connection | |
| Connect directly using the WebSocket URL (token entered separately): | |
| 1. Open the web UI in your browser: `http://localhost:7373/` (adjust host/port if you started the server with different options) | |
| 2. Enter the authentication token in the web interface when prompted | |
| ### 3. Direct URL Connection | |
| Connect directly using the WebSocket URL from a WebSocket-compatible client (not by typing it into a browser address bar), with the token handled separately: |
There was a problem hiding this comment.
Fixed. The documentation now correctly instructs users to open the HTTP UI URL (http://localhost:7373/) in a browser, not a WebSocket URL. The token is entered in the web interface after connecting.
|
|
||
| // Check if already running | ||
| const existingServer = getServer(); | ||
| if (existingServer) { |
There was a problem hiding this comment.
The /remote-control stop path is unreachable when a server is already running because the early existingServer check returns before stopArg is parsed/handled. This prevents stopping the server from within an interactive session; handle stop before the "already running" early-return (or incorporate stopArg into that branch).
| if (existingServer) { | |
| if (existingServer) { | |
| const stopRequested = args.trim().toLowerCase() === 'stop'; | |
| if (stopRequested) { | |
| existingServer.stop(); | |
| setServer(null); | |
| return { | |
| type: 'message', | |
| messageType: 'info', | |
| content: t('Remote control server has been stopped.'), | |
| }; | |
| } |
There was a problem hiding this comment.
Fixed. The stop command is now parsed and handled BEFORE checking for an existing server. This ensures the stop functionality works correctly when a server is already running.
| const clientIp = this.getClientIp( | ||
| client.ws as unknown as http.IncomingMessage, | ||
| ); |
There was a problem hiding this comment.
Rate limiting is meant to be per-client IP, but handleAuthRequest tries to derive the IP by casting client.ws to http.IncomingMessage. A WebSocket instance doesn’t carry socket.remoteAddress, so this will typically fall back to 'unknown' and effectively apply a shared/global rate limit. Store the IP (or the original req) on ClientConnection in handleWebSocketConnection and use that value for rate limiting.
| const clientIp = this.getClientIp( | |
| client.ws as unknown as http.IncomingMessage, | |
| ); | |
| const wsAny = client.ws as any; | |
| const clientIp = | |
| wsAny.socket?.remoteAddress ?? | |
| wsAny._socket?.remoteAddress ?? | |
| 'unknown'; |
There was a problem hiding this comment.
Fixed (Critical). The client IP is now stored in the ClientConnection interface at connection time and used for rate limiting instead of trying to extract it from the WebSocket object.
| // Allow running as standalone command | ||
| if (require.main === module) { | ||
| yargs(hideBin(process.argv)) |
There was a problem hiding this comment.
This package is ESM ("type": "module"), so require/require.main are not available. The require.main === module check will throw at runtime when this module is imported/executed. Use an ESM-compatible entrypoint check (e.g. comparing import.meta.url with pathToFileURL(process.argv[1]).href) or remove this standalone-execution block if it isn’t needed in the published package.
There was a problem hiding this comment.
Fixed. The ESM-incompatible require.main === module check has been replaced with import.meta.url pattern for proper ESM module detection.
| await new Promise<void>((resolve) => { | ||
| qrcode.generate( | ||
| connectionInfo.url, |
There was a problem hiding this comment.
The QR code is generated from connectionInfo.url which is a ws://... URL. Most QR scanners/browsers won’t open the ws: scheme (and the PR description/docs emphasize a web UI at http://.../). Consider encoding the HTTP UI URL (http(s)://host:port/) in the QR code instead, and then performing WebSocket auth from the page.
| await new Promise<void>((resolve) => { | |
| qrcode.generate( | |
| connectionInfo.url, | |
| // Derive an HTTP(S) UI URL from the WebSocket URL for better QR scanner support | |
| let uiUrl = connectionInfo.url; | |
| try { | |
| const wsUrl = new URL(connectionInfo.url); | |
| const protocol = wsUrl.protocol === 'wss:' ? 'https:' : 'http:'; | |
| // Use the same host (and port, if present) for the HTTP(S) UI | |
| uiUrl = `${protocol}//${wsUrl.host}/`; | |
| } catch { | |
| // If URL parsing fails, fall back to the original value | |
| } | |
| await new Promise<void>((resolve) => { | |
| qrcode.generate( | |
| uiUrl, |
There was a problem hiding this comment.
Fixed. The QR code now uses the HTTP UI URL instead of the WebSocket URL, which is more compatible with QR code scanners.
| </div> | ||
|
|
||
| <div class="connecting" id="connecting"> | ||
| <div class="spinner"></div> |
There was a problem hiding this comment.
[Critical] This page serves the live auth token directly in the unauthenticated HTML/JS, then auto-sends it on WebSocket open. Anyone who can load GET / can extract the token and fully authenticate, which defeats the token-based access control model for non-loopback bindings.
Avoid embedding the bearer token in the page at all; require out-of-band token entry or a separate pairing flow.
— gpt-5.4 via Qwen Code /review
There was a problem hiding this comment.
Fixed (Critical). Same fix as comment 2925621747 - the auth token is no longer embedded in the HTML page source. Users enter the token manually in the web UI after connecting.
| const client = this.clients.get(clientId); | ||
| if (!client) return; | ||
|
|
||
| // Check rate limit |
There was a problem hiding this comment.
[Critical] handleAuthRequest() derives the client IP by casting client.ws to IncomingMessage, but the real request object is only available in handleWebSocketConnection(). In practice this collapses auth attempts into a shared unknown bucket instead of rate-limiting per client IP, and it matches the auth/ping test timeouts in this PR.
Please store the real clientIp captured during connection setup on ClientConnection and use that stored value here.
— gpt-5.4 via Qwen Code /review
There was a problem hiding this comment.
Fixed (Critical). Same fix as comment 2925622014 - the client IP is now stored in ClientConnection at connection time and used for rate limiting instead of trying to extract it from the WebSocket object.
| * @returns Connection information object | ||
| */ | ||
| getConnectionInfo(): { url: string; token: string; port: number } { | ||
| const protocol = this.config.secure ? 'wss' : 'ws'; |
There was a problem hiding this comment.
[Suggestion] getConnectionInfo() advertises wss:// whenever secure is true, but start() always creates the server with http.createServer() and never configures TLS. That means enabling secure produces a misleading/nonfunctional secure endpoint instead of actual WSS support.
Either implement TLS-backed HTTP/WebSocket handling or remove/disable the secure flag until it is supported.
— gpt-5.4 via Qwen Code /review
There was a problem hiding this comment.
Fixed (Suggestion). Same fix as comment 2925621701 - the secure flag is now deprecated. The server always uses ws:// since TLS is not yet implemented.
| sessionName, | ||
| }); | ||
|
|
||
| // Initialize with Qwen Code config |
There was a problem hiding this comment.
[Critical] The slash-command QR payload appends the auth token to the URL (...?token=...). That leaks the bearer token through QR contents and contradicts the feature's own design/docs that say tokens are sent in the WebSocket auth message instead of URLs.
Generate the QR code from the UI/WS endpoint only and keep token submission in auth_request.
— gpt-5.4 via Qwen Code /review
There was a problem hiding this comment.
Fixed (Critical). Same fix as comment 2925621661 - the QR code URL no longer contains the token. Token is sent via WebSocket auth_request message.
| } | ||
|
|
||
| // Check if already running | ||
| const existingServer = getServer(); |
There was a problem hiding this comment.
[Suggestion] /remote-control stop is unreachable once a server is already running, because the existingServer early-return executes before stopArg is parsed and handled. As written, the command advertises a stop path that cannot actually stop the running server from the interactive flow.
Please handle stop before the existingServer short-circuit (or merge stop handling into that branch).
— gpt-5.4 via Qwen Code /review
There was a problem hiding this comment.
Fixed (Suggestion). Same fix as comment 2925622000 - the stop command is now parsed and handled BEFORE checking for an existing server.
|
|
||
| // Allow running as standalone command | ||
| if (require.main === module) { | ||
| yargs(hideBin(process.argv)) |
There was a problem hiding this comment.
[Suggestion] This package is ESM ("type": "module"), so require.main === module is not safe here. Importing or executing this file through that standalone path will throw instead of registering the command.
Use an ESM-compatible entrypoint check (for example via import.meta.url) or remove this standalone block if it is not needed.
— gpt-5.4 via Qwen Code /review
There was a problem hiding this comment.
Fixed (Suggestion). Same fix as comment 2925622037 - the ESM-incompatible require.main === module check has been replaced with import.meta.url pattern.
Addresses PR QwenLM#2330 security review comments: - Remove auth token from HTML page source - Remove token from QR code URL (sent via WS message instead) - Fix rate limit IP bypass (store clientIp at connection time) - Add proxy-aware IP detection (X-Forwarded-For, X-Real-IP) - Validate Host header before URL construction - Fix /remote-control stop unreachable - Fix ESM require.main usage - Remove misleading secure flag (always ws:// until TLS) - Remove token from /api/connect and /api/qr-data endpoints - Add missing ws dependency - Remove unused '/' from htmlEscapes mapping
841394d to
c52858a
Compare
Features Implemented
Core Functionality
Security Features
User Interface
CLI Integration
/remote-controlqwen remote-controlFiles Added
docs/remote-control.md- User documentationpackages/cli/src/remote-control/types.ts- Protocol type definitionspackages/cli/src/remote-control/server/RemoteControlServer.ts- Server implementationpackages/cli/src/remote-control/server/RemoteControlServer.test.ts- Unit testspackages/cli/src/remote-control/utils/htmlSanitizer.ts- Security utilitiespackages/cli/src/remote-control/index.ts- Module exportspackages/cli/src/commands/remote-control/index.ts- CLI subcommandpackages/cli/src/ui/commands/remoteControlCommand.ts- Slash commandFiles Modified
packages/cli/package.json- Added ws, @types/ws dependenciespackages/cli/src/config/config.ts- Registered remote-control subcommandpackages/cli/src/services/BuiltinCommandLoader.ts- Registered slash commandKnown Limitations
Current Limitations (Intentional)
Future Enhancements (Not Implemented)
Security Considerations
Production Deployment Requirements
Before deploying to production or internet-facing environments:
secure: truein configRecommended Use Cases
✅ Safe to use:
❌ Not recommended without additional security:
Testing
All tests pass:
Related Issues
Fixes: #1946 (Request remote-control Feature)