Skip to content

Commit 76e97d5

Browse files
committed
fix(cli): harden the Remote Control tunnel and lock
- align ws with the rest of the workspace and restage the web bundle - report the lock holder when acquisition retries run out, instead of a raw EEXIST - reject a non-positive pid in the lock file; process.kill(0, 0) signals our own process group and would pin the lock forever - settle the startup promise when the relay loop throws outside its retry block - buffer management frames between register_ack and the HTTP tunnel, so an immediate open_ws is not dropped - refuse chunked request bodies and derive Content-Length from the bytes actually forwarded - run every startup-cleanup step and keep the original error Correct the guide: the relay does receive the token in the WebSocket handshake, so it has to be trusted.
1 parent 3adad9a commit 76e97d5

8 files changed

Lines changed: 80 additions & 14 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"sourceHash": "c05b9ce73324f5a446966d9f931dc804f0f1e73e78daf14e1479d86e58669c1a",
2+
"sourceHash": "d84e17f04092f5fb9afa9f4d323b614d3945ec8e33abbbc92f5793ad2c30959e",
33
"sourceFileCount": 404
44
}

apps/pythinker-code/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@
108108
"semver": "^7.7.4",
109109
"smol-toml": "^1.6.1",
110110
"tsx": "^4.23.5",
111-
"ws": "^8.18.0",
111+
"ws": "^8.21.3",
112112
"yazl": "^3.3.1",
113113
"zod": "^4.3.6"
114114
},

apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,21 @@ export async function acquireRemoteControlLock(
7373
}
7474
return { release: () => releaseRemoteControlLock(lockPath, info.nonce) };
7575
} catch (error) {
76-
if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || attempt >= MAX_ACQUIRE_ATTEMPTS) {
76+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
7777
throw error;
7878
}
79+
// Read the holder even on the last attempt: a second process can recreate
80+
// the lock between our unlink and our open, and a raw EEXIST tells the
81+
// user nothing about who holds it or how to stop them.
7982
const holder = await readRemoteControlLock(lockPath);
8083
if (holder !== undefined && pidAlive(holder.pid)) {
8184
throw new RemoteControlAlreadyRunningError(holder);
8285
}
86+
if (attempt >= MAX_ACQUIRE_ATTEMPTS) {
87+
throw new Error(
88+
`Unable to acquire the Remote Control lock at ${lockPath}. Another process keeps recreating it.`, { cause: error },
89+
);
90+
}
8391
await removeFile(lockPath);
8492
}
8593
}
@@ -131,6 +139,10 @@ function decodeLock(raw: string): RemoteControlLockInfo | undefined {
131139
const parsed = JSON.parse(raw) as Partial<RemoteControlLockDisk>;
132140
if (
133141
typeof parsed.pid === 'number' &&
142+
// `process.kill(0, 0)` signals our own process group and reports "alive",
143+
// so a corrupt `"pid": 0` would pin the lock forever.
144+
Number.isInteger(parsed.pid) &&
145+
parsed.pid > 0 &&
134146
typeof parsed.nonce === 'string' &&
135147
typeof parsed.local_origin === 'string' &&
136148
typeof parsed.device_id === 'string' &&

apps/pythinker-code/src/cli/sub/web/remote-control.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const RELAY_PING_INTERVAL_MS = 30_000;
5454
const RELAY_SILENCE_TIMEOUT_MS = 300_000;
5555
const BLOCKED_REQUEST_HEADERS = new Set([
5656
'authorization',
57+
'content-length',
5758
'cookie',
5859
'host',
5960
'origin',
@@ -242,6 +243,17 @@ export function parseRawHttpRequest(raw: Buffer): ParsedRawHttpRequest {
242243
}
243244
headers.push([name, value]);
244245
}
246+
// `transfer-encoding` is stripped before forwarding, so a chunked body would
247+
// reach the local server with its chunk framing as entity data. The relay
248+
// sends whole requests, so refuse the framing instead of decoding it.
249+
if (
250+
headers.some(
251+
([name, value]) =>
252+
name.toLowerCase() === 'transfer-encoding' && value.toLowerCase().includes('chunked'),
253+
)
254+
) {
255+
throw new SyntaxError('chunked HTTP request bodies are not supported');
256+
}
245257
return {
246258
method: match[1]!,
247259
path: match[2]!,
@@ -390,7 +402,11 @@ class RemoteControlClient {
390402
this.initialResolve = resolve;
391403
this.initialReject = reject;
392404
});
393-
this.runPromise = this.run();
405+
// `run()` settles `initial` from inside its loop, but a throw from outside
406+
// that loop's try would leave the caller waiting forever.
407+
this.runPromise = this.run().catch((error: unknown) => {
408+
this.rejectInitial(error instanceof Error ? error : new Error(String(error)));
409+
});
394410
await initial;
395411
}
396412

@@ -473,6 +489,14 @@ class RemoteControlClient {
473489
}
474490

475491
const managementEnd = waitForSocketEnd(management);
492+
// The relay may send `open_ws` the moment it acknowledges registration.
493+
// `waitForRelayMessage` has just detached its own listener, so buffer
494+
// everything that lands before the HTTP tunnel is up and replay it.
495+
const earlyManagement: RawData[] = [];
496+
const bufferManagement = (data: RawData): void => {
497+
earlyManagement.push(data);
498+
};
499+
management.on('message', bufferManagement);
476500
const http = await this.connectRelay(
477501
`/v1/remote/http?device_id=${encodeURIComponent(this.deviceId)}`,
478502
);
@@ -481,8 +505,10 @@ class RemoteControlClient {
481505
if (management.readyState !== WebSocket.OPEN) {
482506
throw new Error('management connection closed');
483507
}
508+
management.off('message', bufferManagement);
484509
management.on('message', (data) => this.handleManagementMessage(data));
485510
http.on('message', (data) => this.handleHttpMessage(data));
511+
for (const data of earlyManagement) this.handleManagementMessage(data);
486512
this.reconnectAttempt = 0;
487513
this.relayOnline = true;
488514
this.onStatus('relay_connected');
@@ -870,6 +896,8 @@ function requestLocalHttp(
870896
path: parsed.path,
871897
headers: [
872898
...filterForwardRequestHeaders(parsed.headers, serverToken),
899+
'Content-Length',
900+
String(parsed.body.length),
873901
'Host',
874902
origin.host,
875903
],

apps/pythinker-code/src/cli/sub/web/run.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -426,11 +426,24 @@ async function runServerInProcess(
426426
try {
427427
await hooks.onReady?.(running.address);
428428
} catch (error) {
429-
try {
430-
await hooks.onShutdown?.('startup_failed');
431-
} finally {
432-
await running.close();
433-
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
429+
// Every cleanup step runs even when an earlier one fails, and none of them
430+
// may replace the startup error the caller needs to see.
431+
for (const step of [
432+
async () => hooks.onShutdown?.('startup_failed'),
433+
async () => running.close(),
434+
async () => shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }),
435+
]) {
436+
try {
437+
await step();
438+
} catch (cleanupError) {
439+
running.logger.error(
440+
{
441+
err:
442+
cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)),
443+
},
444+
'startup cleanup step failed',
445+
);
446+
}
434447
}
435448
throw error;
436449
}

apps/pythinker-code/test/cli/web/remote-control.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,16 +117,27 @@ describe('Remote Control HTTP forwarding', () => {
117117
);
118118
expect(parsed).toMatchObject({ method: 'POST', path: '/api/v1/messages?q=1' });
119119
expect(parsed.body.toString()).toBe('data');
120+
// Content-Length is dropped here and re-derived from the body that is
121+
// actually forwarded, so a relay cannot desync the local server with a
122+
// length that disagrees with the bytes.
120123
expect(filterForwardRequestHeaders(parsed.headers, 'local-token')).toEqual([
121124
'X-Keep',
122125
'yes',
123-
'Content-Length',
124-
'4',
125126
'Authorization',
126127
'Bearer local-token',
127128
]);
128129
});
129130

131+
it('refuses a chunked request body instead of forwarding its framing as data', () => {
132+
expect(() =>
133+
parseRawHttpRequest(
134+
Buffer.from(
135+
'POST /api/v1/messages HTTP/1.1\r\nHost: relay.example\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ndata\r\n0\r\n\r\n',
136+
),
137+
),
138+
).toThrow(SyntaxError);
139+
});
140+
130141
it('rejects absolute-form and malformed request targets', () => {
131142
expect(() =>
132143
parseRawHttpRequest(Buffer.from('GET https://example.test/ HTTP/1.1\r\n\r\n')),

docs/guides/remote-control.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ The terminal prints a QR code, a link, and the path of a PNG copy of the QR code
2626

2727
The link grants control of this machine. Do not share the link or the QR code.
2828

29-
The link carries no access token. Requests reach the local server through the tunnel, and the Pythinker Code process on this machine adds the bearer token to each one, so the token never leaves this machine.
29+
The link itself carries no access token: requests arrive through the tunnel, and the Pythinker Code process on this machine adds the bearer token to each one before it reaches the local server. The QR code, the printed link, and the PNG on disk hold no credential.
30+
31+
The relay is a different matter. Pythinker Code authenticates to it with the same token, sent in the WebSocket handshake, and every request and response passes through it in the clear. Use a relay you operate or otherwise trust. Rotate the token with `pythinker web rotate-token` if a relay is ever compromised.
3032

3133
## Relay
3234

@@ -40,4 +42,4 @@ pythinker rc --relay-origin https://relay.example.com
4042

4143
## Stop it
4244

43-
Press `Ctrl+C`. The tunnel closes with the server.
45+
Press `Ctrl-C`. The tunnel closes with the server.

pnpm-lock.yaml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)