Skip to content

feat: remote-control feature for browser-based CLI interaction#2330

Open
ossaidqadri wants to merge 1 commit intoQwenLM:mainfrom
ossaidqadri:feature/remote-control
Open

feat: remote-control feature for browser-based CLI interaction#2330
ossaidqadri wants to merge 1 commit intoQwenLM:mainfrom
ossaidqadri:feature/remote-control

Conversation

@ossaidqadri
Copy link
Copy Markdown
Contributor

Features Implemented

Core Functionality

  • HTTP + WebSocket server for real-time bidirectional communication
  • Web-based UI accessible at http://localhost:7373/
  • Token-based authentication with cryptographically secure tokens (64 hex chars)
  • Real-time message synchronization between CLI and browser
  • QR code display for easy mobile connection (via qrcode-terminal)

Security Features

  • Rate limiting: Max 5 auth attempts per minute per IP
  • Connection limits: Max 5 concurrent connections
  • Message size validation: Max 1MB per WebSocket message
  • Idle timeout: 30-minute session timeout for inactive connections
  • HTML sanitization: XSS prevention via explicit character escaping
  • Security headers: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection
  • Token transmission via WebSocket messages (not URL parameters)
  • WSS support option for encrypted connections

User Interface

  • Clean, modern web UI with gradient background
  • Security warning banner for non-encrypted connections
  • Real-time connection status indicator
  • Token display with copy-friendly formatting
  • WebSocket connection status (Connecting → Connected)
  • Message area showing conversation history
  • Input field for sending messages to CLI

CLI Integration

  • Slash command: /remote-control
  • CLI subcommand: qwen remote-control
  • Custom options: --port, --host, --name, --stop
  • Clear startup messages with connection details
  • Graceful shutdown on Ctrl+C

Files Added

  • docs/remote-control.md - User documentation
  • packages/cli/src/remote-control/types.ts - Protocol type definitions
  • packages/cli/src/remote-control/server/RemoteControlServer.ts - Server implementation
  • packages/cli/src/remote-control/server/RemoteControlServer.test.ts - Unit tests
  • packages/cli/src/remote-control/utils/htmlSanitizer.ts - Security utilities
  • packages/cli/src/remote-control/index.ts - Module exports
  • packages/cli/src/commands/remote-control/index.ts - CLI subcommand
  • packages/cli/src/ui/commands/remoteControlCommand.ts - Slash command

Files Modified

  • packages/cli/package.json - Added ws, @types/ws dependencies
  • packages/cli/src/config/config.ts - Registered remote-control subcommand
  • packages/cli/src/services/BuiltinCommandLoader.ts - Registered slash command

Known Limitations

Current Limitations (Intentional)

  1. Local-only by default: Server binds to localhost for security
  2. No encryption by default: Uses plain WS, WSS must be explicitly enabled
  3. Single session: Only one CLI session can be controlled at a time
  4. No file uploads: Cannot upload files through web interface
  5. Limited tool execution: Some CLI tools require local terminal access

Future Enhancements (Not Implemented)

  1. Mobile app integration: No dedicated mobile app (web UI is responsive)
  2. Public relay: No external relay server (like claude.ai/code)
  3. Access control lists: No IP whitelisting/blacklisting
  4. Session revocation: Cannot kick specific connected clients
  5. Audit logging: No security event logging
  6. Metrics/monitoring: No Prometheus-style metrics endpoint
  7. Token rotation: Tokens don't rotate during session lifetime
  8. Multi-factor auth: Single token authentication only

Security Considerations

Production Deployment Requirements

Before deploying to production or internet-facing environments:

  • Enable WSS (WebSocket Secure) - set secure: true in config
  • Configure firewall rules to restrict access
  • Consider implementing IP whitelisting
  • Enable audit logging for security events
  • Set up monitoring for connection metrics
  • Define token rotation policy
  • Create incident response plan for compromised tokens

Recommended Use Cases

Safe to use:

  • Local development (localhost only)
  • Trusted internal networks
  • Second screen monitoring
  • Screen sharing alternative

⚠️ Use with caution:

  • External network access (requires WSS)
  • Public internet exposure (requires additional security measures)

Not recommended without additional security:

  • Production environments without WSS
  • Public networks without firewall rules
  • Sensitive/confidential work without encryption

Testing

All tests pass:

# Unit tests
bun test packages/cli/src/remote-control/server/RemoteControlServer.test.ts

# UX flow test
node test-ux-flow.js

# Manual testing
node test-remote-control-launcher.js

Related Issues

Fixes: #1946 (Request remote-control Feature)

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +131 to +132
// Generate QR code for easy connection
const qrCodeUrl = `${connectionInfo.url}?token=${connectionInfo.token}`;
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +124 to +127
// Create HTTP server for serving static files and health checks
this.httpServer = http.createServer((req, res) => {
this.handleHttpRequest(req, res);
});
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@-

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
expiresAt: Date.now() + this.config.tokenExpiryMs,

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +525 to +530
<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>
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
<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>

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`;
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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`;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/remote-control.md
Comment on lines +74 to +79
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):
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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://....

Suggested change
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:

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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.'),
};
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +878 to +880
const clientIp = this.getClientIp(
client.ws as unknown as http.IncomingMessage,
);
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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';

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +226 to +228
// Allow running as standalone command
if (require.main === module) {
yargs(hideBin(process.argv))
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The ESM-incompatible require.main === module check has been replaced with import.meta.url pattern for proper ESM module detection.

Comment on lines +165 to +167
await new Promise<void>((resolve) => {
qrcode.generate(
connectionInfo.url,
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@ossaidqadri ossaidqadri force-pushed the feature/remote-control branch from 841394d to c52858a Compare April 20, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request remote-control Feature

4 participants