Skip to content

Commit e75fd62

Browse files
committed
fix: cap tunnel response size, recheck store epoch, and resolve review findings
The remote-control tunnel now destroys oversized local responses with a 502 instead of buffering without bound, and the early-frame bridge parameter is required. The search indexer bounds its pending wire record, counts skipped sessions in the pass outcome, and narrows the missing-file error properly. The minidb query store rechecks the store epoch after a successful operation so a concurrent rebuild cannot swallow the result. Panel copy drops the graduated experimental label, the slash-command table regains its fourth cell, and two CLI tests assert presence before shape.
1 parent 95e8723 commit e75fd62

7 files changed

Lines changed: 28 additions & 13 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function formatRemoteControlOutput(options: RemoteControlOutputOptions):
5858
);
5959
return [
6060
'',
61-
` ${title('Pythinker Remote Control ready')} ${muted(`${getVersion()} (experimental)`)}`,
61+
` ${title('Pythinker Remote Control ready')} ${muted(getVersion())}`,
6262
` ${muted('Use Pythinker Code on this machine from your phone or another computer.')}`,
6363
'',
6464
` ${label('1.')} Scan the QR code, or open ${link(options.url)}`,
@@ -72,7 +72,7 @@ export function formatRemoteControlOutput(options: RemoteControlOutputOptions):
7272
` ${label('QR code PNG: ')}${options.pngPath} ${muted('(open this if the QR above does not scan)')}`,
7373
` ${label('Local UI: ')}${accent(localBase)}${dim(localFrag)} ${muted('(LAN: --host)')}`,
7474
'',
75-
` ${muted('Experimental —')} ${docs} ${muted('·')} ${feedback}`,
75+
` ${muted('Docs:')} ${docs} ${muted('·')} ${feedback}`,
7676
` ${label('Logs: ')}${muted('off (--log-level info)')} ${muted('·')} ${label('Stop: ')}${muted('Ctrl+C')}`,
7777
'',
7878
].join('\n');

apps/pythinker-code/test/cli/options.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Run: pnpm -C apps/pythinker-code exec vitest run test/cli/options.test.ts
66
*/
77

8-
import { describe, expect, it, vi } from 'vitest';
8+
import { describe, expect, it } from 'vitest';
99

1010
import { createProgram } from '#/cli/commands';
1111
import type { CLIOptions } from '#/cli/options';

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ describe('`pythinker web` opens the browser', () => {
546546
const remoteControlOption = makeProgram()
547547
.commands.find((command) => command.name() === 'web')!
548548
.options.find((option) => option.long === '--remote-control');
549+
expect(remoteControlOption).toBeDefined();
549550
expect(remoteControlOption?.hidden).toBeFalsy();
550551
});
551552
});

docs/reference/slash-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Some commands are only available in the idle state. Executing these commands whi
1616
| `/logout` || Clear credentials for the currently selected account | No |
1717
| `/provider` || Open the interactive provider manager to view, add, and remove configured providers. See [Platforms & Models — `/provider` and provider management](../configuration/providers.md#provider-—-interactive-provider-management) | Yes |
1818
| `/model` || Switch the LLM model used in the current session | Yes |
19-
| `/secondary-model` | `/subagent-model` | Pick the default model for subagents (writes `[secondary_model] default_model`; see the [subagent model pool](../configuration/config-files.md#subagent-model-pool)). Yes |
19+
| `/secondary-model` | `/subagent-model` | Pick the default model for subagents (writes `[secondary_model] default_model`; see the [subagent model pool](../configuration/config-files.md#subagent-model-pool)) | Yes |
2020
| `/settings` | `/config` | Open the settings panel inside the TUI | Yes |
2121
| `/experiments` | `/experimental` | Open the experimental feature panel | Yes |
2222
| `/permission` || Select a permission mode | Yes |

packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
137137
const result = await op(db);
138138
if (kind === 'write') this.transientWriteFailures = 0;
139139
else this.transientReadFailures = 0;
140+
if (expectedStoreEpoch !== undefined && expectedStoreEpoch !== this.storeEpochCounter) {
141+
throw new QueryStoreRebuiltError();
142+
}
140143
return result;
141144
} catch (error) {
142145
if (classifyStorageError(error) !== 'rebuild') {

packages/agent-gateway/src/search/indexCore.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ function legacyFileMetaKey(filePath: string): string {
6060

6161
const WIRE_READ_CHUNK_BYTES = 1 << 20;
6262
const WIRE_BATCH_OPS = 1_000;
63+
const MAX_WIRE_PENDING_BYTES = 4 * 1024 * 1024;
6364
const SYNC_ROUND_BYTE_BUDGET = 64 << 20;
6465
const SYNC_ROUND_TIME_BUDGET_MS = 30_000;
6566
const SYNC_FAILURE_ESCALATION_LIMIT = 5;
@@ -535,6 +536,7 @@ export class SearchIndexCore {
535536
if (result.failed) {
536537
const count = (this.sessionSyncFailures.get(summary.id) ?? 0) + 1;
537538
this.sessionSyncFailures.set(summary.id, count);
539+
failures += 1;
538540
if (count >= SESSION_SYNC_FAILURE_SKIP_LIMIT) {
539541
this.sessionSyncFailures.delete(summary.id);
540542
this.sessionSyncSkips.set(summary.id, { at: Date.now(), updatedAt: summary.updatedAt });
@@ -543,7 +545,6 @@ export class SearchIndexCore {
543545
{ sessionId: summary.id, error: result.error },
544546
);
545547
} else {
546-
failures += 1;
547548
this.log.warn('global search: failed to index session', {
548549
sessionId: summary.id,
549550
error: result.error,
@@ -669,7 +670,7 @@ export class SearchIndexCore {
669670
try {
670671
handle = await open(file.path, 'r');
671672
} catch (error) {
672-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
673+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
673674
return { truncated: false, failed: false };
674675
}
675676
return { truncated: false, failed: true, error: errorMessage(error) };
@@ -801,6 +802,7 @@ export class SearchIndexCore {
801802
pending.length > 0
802803
? Buffer.concat([pending, slice.subarray(start)])
803804
: Buffer.from(slice.subarray(start));
805+
if (pending.length > MAX_WIRE_PENDING_BYTES) break;
804806
if (finishing && completedRecord) break;
805807
if (ops.length >= WIRE_BATCH_OPS) {
806808
ops.push({ op: 'set', key: metaKey, value: fileMeta(byteCursor, turnState, stepState) });

packages/remote-control/src/remote-control.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export function resolveRelayKey(
5353

5454
const MAX_HTTP_HEADER_BYTES = 64 * 1024;
5555
const MAX_HTTP_REQUEST_BYTES = 10 * 1024 * 1024;
56+
const MAX_HTTP_RESPONSE_BYTES = 64 * 1024 * 1024;
5657
const HTTP_REQUEST_TIMEOUT_MS = 30_000;
5758
const REGISTER_TIMEOUT_MS = 10_000;
5859
const MAX_RECONNECT_DELAY_MS = 30_000;
@@ -922,7 +923,17 @@ function requestLocalHttp(
922923
},
923924
(response) => {
924925
const chunks: Buffer[] = [];
925-
response.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk)));
926+
let receivedBytes = 0;
927+
response.on('data', (chunk: Buffer | string) => {
928+
receivedBytes += chunk.length;
929+
if (receivedBytes > MAX_HTTP_RESPONSE_BYTES) {
930+
response.destroy(
931+
new Error(`Remote Control response exceeds ${MAX_HTTP_RESPONSE_BYTES} bytes`),
932+
);
933+
return;
934+
}
935+
chunks.push(Buffer.from(chunk));
936+
});
926937
response.once('error', reject);
927938
response.once('end', () => {
928939
void (async (): Promise<Buffer> => {
@@ -1018,7 +1029,7 @@ function bridgeSockets(
10181029
left: WebSocket,
10191030
right: WebSocket,
10201031
onClose: () => void,
1021-
earlyLeftFrames?: [RawData, boolean][],
1032+
earlyLeftFrames: [RawData, boolean][],
10221033
): void {
10231034
let closed = false;
10241035
const closeBoth = (code = 1000, reason = Buffer.alloc(0)): void => {
@@ -1029,11 +1040,9 @@ function bridgeSockets(
10291040
if (left.readyState === WebSocket.OPEN) left.close(safeCode, reason);
10301041
if (right.readyState === WebSocket.OPEN) right.close(safeCode, reason);
10311042
};
1032-
if (earlyLeftFrames !== undefined) {
1033-
left.removeAllListeners('message');
1034-
for (const [data, isBinary] of earlyLeftFrames) {
1035-
if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary });
1036-
}
1043+
left.removeAllListeners('message');
1044+
for (const [data, isBinary] of earlyLeftFrames) {
1045+
if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary });
10371046
}
10381047
left.on('message', (data, isBinary) => {
10391048
if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary });

0 commit comments

Comments
 (0)