Skip to content

Commit e5992dc

Browse files
committed
fix(remote): guard mobile input while disconnected, harden WS tests
1 parent fa46d9b commit e5992dc

3 files changed

Lines changed: 33 additions & 2 deletions

File tree

electron/remote/server-ws.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,11 @@ function connectAndAuth(token: string): Promise<WebSocket> {
6060
function 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+
111138
describe('coordinator token over WebSocket', () => {
112139
it('forwards input to the agent PTY', async () => {
113140
const ws = await connectAndAuth(coordinatorToken);

electron/remote/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,8 @@ export function startRemoteServer(opts: {
831831
const msg = parseClientMessage(String(raw));
832832
if (!msg) return;
833833

834-
// Handle first-message auth. Only coordinator token grants WS access.
834+
// Handle first-message auth. Coordinator and mobile tokens grant WS
835+
// access; subtask tokens are denied.
835836
if (msg.type === 'auth') {
836837
const tokenType = classifyCandidate(msg.token);
837838
if (tokenType === 'coordinator' || tokenType === 'mobile') {

src/remote/AgentDetail.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ export function AgentDetail(props: AgentDetailProps) {
180180
function handleSend() {
181181
const text = inputText();
182182
if (!text) return;
183+
// Keep the typed text while disconnected — send() silently drops
184+
// messages on a non-open socket, so clearing here would lose input.
185+
if (status() !== 'connected') return;
183186
const id = ++lastSendId;
184187
// Send text and Enter separately — TUI apps (Claude Code, Codex)
185188
// treat \r inside a pasted block as a literal, not as confirmation.

0 commit comments

Comments
 (0)