Skip to content

feat(cli): add remote-control worker server#3930

Closed
chiga0 wants to merge 3 commits into
feat/remote-controlfrom
feat/remote-control-worker
Closed

feat(cli): add remote-control worker server#3930
chiga0 wants to merge 3 commits into
feat/remote-controlfrom
feat/remote-control-worker

Conversation

@chiga0

@chiga0 chiga0 commented May 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

This is PR 2 of the 3-PR stacked remote-control implementation. It is stacked on #3929.

Stack split:

  1. Foundation (feat(cli): add remote-control foundation #3929): design doc plus stream-json interrupt/session lifecycle fixes.
  2. Worker server (this PR, feat(cli): add remote-control worker server #3930): local HTTP/WebSocket remote-control server, pairing/auth, event replay, worker session runner, command routing, LAN URL reporting, and minimal browser/mobile UI.
  3. Current TUI attach (feat(cli): attach remote-control to current TUI #3931): attach the live Ink TUI to the same server via dual-output and remote-input bridges.

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

  • Binds to 127.0.0.1 by default.
  • Refuses non-loopback binds unless --allow-lan is explicit.
  • Reports LAN URLs when LAN mode is enabled, so mobile devices do not have to guess the host address.
  • Uses one-time pairing token plus issued client tokens; raw tokens are not stored.
  • Enforces max payload, max clients, origin checks, auth failure rate limiting, and CSP for static UI.

Validation

  • cd packages/cli && npm run typecheck
  • cd packages/cli && npx vitest run src/remoteControl/EventLog.test.ts src/remoteControl/PairingManager.test.ts src/remoteControl/RemoteSessionRunner.test.ts src/remoteControl/RemoteControlServer.test.ts
  • git diff --check feat/remote-control...feat/remote-control-worker

@chiga0 chiga0 force-pushed the feat/remote-control branch from ca5b8e0 to 267c3d2 Compare May 8, 2026 02:08
@chiga0 chiga0 force-pushed the feat/remote-control-worker branch 2 times, most recently from 593cf8d to cf5f16e Compare May 8, 2026 02:34

@wenshao wenshao left a comment

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.

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

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

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

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

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

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

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

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

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

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

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

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] 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) => {

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] 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));

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] When the child process exits, onExit sets this.closed = true but does not close this.stdoutReader. Buffered stdout lines can still trigger handleStdoutLinehandleChildMessage, 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 {

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] 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 () => {

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] 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

@chiga0

chiga0 commented May 15, 2026

Copy link
Copy Markdown
Collaborator Author

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.

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.

2 participants