feat(cli): add remote-control worker server#3930
Conversation
3bac087 to
fc1692b
Compare
c2731ad to
ca5b8e0
Compare
ca5b8e0 to
267c3d2
Compare
593cf8d to
cf5f16e
Compare
cf5f16e to
8b62124
Compare
9d30917 to
8c15016
Compare
wenshao
left a comment
There was a problem hiding this comment.
Missing test coverage: SessionRegistry.ts (311 lines, core state machine) has no test file. protocol.ts (parseRemoteEnvelope, requireStringField — security boundary parsing) has no test file. RemoteControlServer.test.ts covers only 3 scenarios for 673 lines of server code.
| if ( | ||
| state.authenticated && | ||
| (state.subscriptions.has(sessionId) || | ||
| envelope.type === 'session/state') |
There was a problem hiding this comment.
[Critical] broadcast() sends session/state events to all authenticated clients regardless of their subscription to that session — the condition envelope.type === 'session/state' bypasses state.subscriptions.has(sessionId). In --allow-lan mode, this leaks session metadata (cwd, model, permissionMode) across potentially different users.
| envelope.type === 'session/state') | |
| private broadcast(sessionId: string, envelope: RemoteEnvelope): void { | |
| for (const [ws, state] of this.clients.entries()) { | |
| if (state.authenticated && state.subscriptions.has(sessionId)) { | |
| this.sendEnvelope(ws, envelope); | |
| } | |
| } | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| return requestId; | ||
| } | ||
|
|
||
| private sendJson(value: unknown): void { |
There was a problem hiding this comment.
[Critical] sendJson() writes to child.stdin without checking the return value, without a drain listener, and without an error handler on the stdin stream. Six code paths (submit, respondToTool, interrupt, setModel, setPermissionMode, getContextUsage) all call sendJson. An EPIPE (child exited) or backpressure condition silently drops data — the remote client receives command/ack before the write actually succeeds.
| private sendJson(value: unknown): void { | |
| private sendJson(value: unknown): void { | |
| if (!this.child || this.closed) { | |
| throw new Error('Remote session runner is not running'); | |
| } | |
| try { | |
| const ok = this.child.stdin.write(`${JSON.stringify(value)}\n`); | |
| if (!ok) { | |
| // backpressure: consider waiting for drain before ack | |
| this.child.stdin.once('drain', () => { /* ack after drain */ }); | |
| } | |
| } catch (err) { | |
| this.closed = true; | |
| this.options.onError?.(err as Error); | |
| throw err; | |
| } | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| } | ||
| const valid = this.safeEquals(this.pairingToken.hash, this.hash(token)); | ||
| if (valid) { | ||
| this.pairingToken = null; |
There was a problem hiding this comment.
[Critical] verifyPairingToken immediately sets this.pairingToken = null upon successful validation, consuming the one-time token. If the WebSocket drops between auth/pair validation and the auth/result response (lines 34-40), the client never receives the clientToken and is permanently locked out — the pairing token is already consumed. This requires a server restart to recover.
| this.pairingToken = null; | |
| // Defer token invalidation until auth/result is confirmed sent: | |
| // In RemoteControlServer.handleAuth, set pairingToken = null only after | |
| // sendEnvelope(ws, makeEnvelope('auth/result', { clientToken, ... })) succeeds. |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| return event; | ||
| } | ||
|
|
||
| replay(since?: number): EventLogReplay { |
There was a problem hiding this comment.
[Critical] replay() without a since argument always returns truncated: false (line 47), even when the ring buffer has evicted older events (line 39-40). A client requesting full history gets silently truncated data and believes it has the complete event stream.
| replay(since?: number): EventLogReplay { | |
| replay(since?: number): EventLogReplay { | |
| if (since === undefined) { | |
| const truncated = this.events.length >= this.limit && this.nextSeq > this.limit; | |
| return { events: [...this.events], truncated }; | |
| } | |
| // ... | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| return this.getRecord(sessionId).log.replay(since); | ||
| } | ||
|
|
||
| submit(sessionId: string, text: string): void { |
There was a problem hiding this comment.
[Critical] Session operations (submit, respondToTool, interrupt, setModel, setPermissionMode, getContextUsage) delegate directly to the runner without checking the session's current state. A closed or errored session's runner may throw (sendJson guards against this.closed), but the error is caught generically in RemoteControlServer.handleWsMessage without including the sessionId in the error response, making debugging extremely difficult.
| submit(sessionId: string, text: string): void { | |
| // In each mutating method, guard on state first: | |
| submit(sessionId: string, text: string): void { | |
| const record = this.getRecord(sessionId); | |
| if (record.snapshot.state === 'closed' || record.snapshot.state === 'error') { | |
| throw new Error(`Cannot submit: session ${sessionId} is ${record.snapshot.state}`); | |
| } | |
| record.runner.submit(text); | |
| this.setState(sessionId, 'working'); | |
| this.append(sessionId, 'user/submit', { text }); | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| } | ||
|
|
||
| const firstSeq = this.events[0]?.seq; | ||
| const truncated = firstSeq !== undefined && since < firstSeq - 1; |
There was a problem hiding this comment.
[Suggestion] replay(since) returns truncated: false when the event log is empty but since > 0. When this.events is empty, firstSeq is undefined, making truncated evaluate to false regardless of since's value. A reconnecting client receives { events: [], truncated: false }, which is indistinguishable from a genuinely empty log (vs. all events evicted).
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| this.append(id, 'event/append', { childExit: details }); | ||
| this.setState(id, 'closed'); | ||
| }, | ||
| onError: (error) => { |
There was a problem hiding this comment.
[Suggestion] The onError callback in createSession unconditionally calls this.setState(id, 'error', error.message). If the child process triggers an error after closeSession() has set state to 'closed', the state reverts from 'closed' to 'error', violating the state machine invariant. Add a guard: skip setState when current state is 'closed'.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| crlfDelay: Infinity, | ||
| }); | ||
|
|
||
| this.stdoutReader.on('line', (line) => this.handleStdoutLine(line)); |
There was a problem hiding this comment.
[Suggestion] When the child process exits, onExit sets this.closed = true but does not close this.stdoutReader. Buffered stdout lines can still trigger handleStdoutLine → handleChildMessage, which can transition state back to 'idle' or 'working', corrupting the 'closed' terminal state. Add this.stdoutReader?.close() in onExit or a if (this.closed) return; guard in handleStdoutLine.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| this.wss = null; | ||
| } | ||
|
|
||
| getInfo(): RemoteControlServerInfo { |
There was a problem hiding this comment.
[Suggestion] getInfo() returns this.pairingToken indefinitely, even after PairingManager.verifyPairingToken() has consumed the internal pairing record. After a successful pairing, getInfo() and the /api/pairing endpoint still report the original token value, misleading clients into believing a valid pairing token exists. Clear this.pairingToken after the first successful authentication.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| ); | ||
| } | ||
|
|
||
| const shutdown = async () => { |
There was a problem hiding this comment.
[Suggestion] The SIGINT/SIGTERM shutdown handler uses void shutdown() where shutdown contains await server.stop(); process.exit(0). If server.stop() throws, the promise rejection is unhandled (due to void), and process.exit(0) never executes — the process hangs indefinitely on await new Promise<void>(() => {}). Wrap shutdown in try/catch with process.exit(1) on error.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
|
Closing for now — deprioritized while the daemon PRs (#3889 et al.) land. The remote-control surface will be reconsidered after the daemon layer stabilizes; reopening this stack would need a rebase on the new daemon foundation anyway. Branch preserved for later resurrection. |
Summary
This is PR 2 of the 3-PR stacked remote-control implementation. It is stacked on #3929.
Stack split:
This PR adds the first usable remote-control mode:
qwen remote-control. Remote clients can pair, create worker sessions, submit prompts, replay history, approve/deny tools, interrupt turns, switch model, switch permission mode, and query context usage through the stream-json control plane.Security / mobile notes
127.0.0.1by default.--allow-lanis explicit.Validation
cd packages/cli && npm run typecheckcd packages/cli && npx vitest run src/remoteControl/EventLog.test.ts src/remoteControl/PairingManager.test.ts src/remoteControl/RemoteSessionRunner.test.ts src/remoteControl/RemoteControlServer.test.tsgit diff --check feat/remote-control...feat/remote-control-worker