Update files

This commit is contained in:
Mohamed Boudra
2026-02-13 09:09:39 +00:00
parent 4948ba581a
commit 69f851aa14
19 changed files with 2120 additions and 28 deletions
+1
View File
@@ -1 +1,2 @@
rust 1.85.1 rust 1.85.1
nodejs 22.20.0
@@ -17,6 +17,18 @@ type TauriWebSocketModule = {
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>; connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
}; };
function toTauriOutgoingMessage(
data: string | Uint8Array | ArrayBuffer
): string | number[] {
if (typeof data === "string") {
return data;
}
if (data instanceof ArrayBuffer) {
return Array.from(new Uint8Array(data));
}
return Array.from(data);
}
function isTauriEnvironment(): boolean { function isTauriEnvironment(): boolean {
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined; return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
} }
@@ -49,7 +61,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
const errorHandlers = new Set<(event?: unknown) => void>(); const errorHandlers = new Set<(event?: unknown) => void>();
const messageHandlers = new Set<(data: unknown) => void>(); const messageHandlers = new Set<(data: unknown) => void>();
const pendingSends: string[] = []; const pendingSends: Array<string | number[]> = [];
let closeRequested: { code?: number; reason?: string } | null = null; let closeRequested: { code?: number; reason?: string } | null = null;
const emitOpen = () => { const emitOpen = () => {
@@ -131,6 +143,10 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
emitMessage({ data: msg.data }); emitMessage({ data: msg.data });
return; return;
} }
if (msg.type === "Binary") {
emitMessage({ data: new Uint8Array(msg.data) });
return;
}
if (msg.type === "Close") { if (msg.type === "Close") {
emitClose(msg.data ?? undefined); emitClose(msg.data ?? undefined);
return; return;
@@ -168,11 +184,12 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
const transport: DaemonTransport = { const transport: DaemonTransport = {
send: (data) => { send: (data) => {
if (disposed) return; if (disposed) return;
const outgoing = toTauriOutgoingMessage(data);
if (!ws) { if (!ws) {
pendingSends.push(data); pendingSends.push(outgoing);
return; return;
} }
void ws.send(data).catch((error) => emitError(error)); void ws.send(outgoing).catch((error) => emitError(error));
}, },
close: (code?: number, reason?: string) => { close: (code?: number, reason?: string) => {
if (disposed) return; if (disposed) return;
+2 -1
View File
@@ -23,8 +23,9 @@ function createNodeWebSocketFactory() {
return (url: string, options?: { headers?: Record<string, string> }) => { return (url: string, options?: { headers?: Record<string, string> }) => {
return new WebSocket(url, { headers: options?.headers }) as unknown as { return new WebSocket(url, { headers: options?.headers }) as unknown as {
readyState: number readyState: number
send: (data: string) => void send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void close: (code?: number, reason?: string) => void
binaryType?: string
on: (event: string, listener: (...args: unknown[]) => void) => void on: (event: string, listener: (...args: unknown[]) => void) => void
off: (event: string, listener: (...args: unknown[]) => void) => void off: (event: string, listener: (...args: unknown[]) => void) => void
} }
+47 -4
View File
@@ -12,6 +12,9 @@ import {
decrypt, decrypt,
} from "./crypto.js"; } from "./crypto.js";
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25;
async function getAvailablePort(): Promise<number> { async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server = net.createServer(); const server = net.createServer();
@@ -46,7 +49,36 @@ async function waitForServer(port: number, timeout = 15000): Promise<void> {
throw new Error(`Server did not start on port ${port} within ${timeout}ms`); throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
} }
describe("E2E Relay with E2EE", () => { async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const probeUrl = `ws://127.0.0.1:${port}/ws?serverId=${serverId}&role=server`;
const opened = await new Promise<boolean>((resolve) => {
const ws = new WebSocket(probeUrl);
const timer = setTimeout(() => {
ws.terminate();
resolve(false);
}, 5000);
ws.once("open", () => {
clearTimeout(timer);
ws.close(1000, "probe");
resolve(true);
});
ws.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
if (opened) {
return;
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(`Relay WebSocket endpoint not ready on port ${port} within ${timeout}ms`);
}
(shouldRunRelayE2e ? describe : describe.skip)("E2E Relay with E2EE", () => {
let relayPort: number; let relayPort: number;
let relayProcess: ChildProcess | null = null; let relayProcess: ChildProcess | null = null;
@@ -54,7 +86,17 @@ describe("E2E Relay with E2EE", () => {
relayPort = await getAvailablePort(); relayPort = await getAvailablePort();
relayProcess = spawn( relayProcess = spawn(
"npx", "npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)], [
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
{ {
cwd: process.cwd(), cwd: process.cwd(),
env: { ...process.env }, env: { ...process.env },
@@ -79,6 +121,7 @@ describe("E2E Relay with E2EE", () => {
}); });
await waitForServer(relayPort, 30000); await waitForServer(relayPort, 30000);
await waitForRelayWebSocketReady(relayPort, 60000);
}); });
afterAll(async () => { afterAll(async () => {
@@ -88,7 +131,7 @@ describe("E2E Relay with E2EE", () => {
} }
}); });
it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 20_000 }, async () => { it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 90_000 }, async () => {
const serverId = "test-session-" + Date.now(); const serverId = "test-session-" + Date.now();
const clientId = "clt_test_" + Date.now() + "_" + Math.random().toString(36).slice(2); const clientId = "clt_test_" + Date.now() + "_" + Math.random().toString(36).slice(2);
@@ -255,7 +298,7 @@ describe("E2E Relay with E2EE", () => {
clientWs.close(); clientWs.close();
}); });
it("relay only sees opaque bytes after handshake", async () => { it("relay only sees opaque bytes after handshake", { timeout: 90_000 }, async () => {
const serverId = "opaque-test-" + Date.now(); const serverId = "opaque-test-" + Date.now();
const clientId = "clt_opaque_" + Date.now() + "_" + Math.random().toString(36).slice(2); const clientId = "clt_opaque_" + Date.now() + "_" + Math.random().toString(36).slice(2);
@@ -1,6 +1,13 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest"; import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
import { DaemonClient, type DaemonTransport } from "./daemon-client"; import { DaemonClient, type DaemonTransport } from "./daemon-client";
import {
BinaryMuxChannel,
TerminalBinaryMessageType,
asUint8Array,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from "../shared/binary-mux.js";
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>(); expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
expectTypeOf<"getHighlightedDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>(); expectTypeOf<"getHighlightedDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
@@ -15,7 +22,7 @@ function createMockLogger() {
} }
function createMockTransport() { function createMockTransport() {
const sent: string[] = []; const sent: Array<string | Uint8Array | ArrayBuffer> = [];
let onMessage: (data: unknown) => void = () => {}; let onMessage: (data: unknown) => void = () => {};
let onOpen: () => void = () => {}; let onOpen: () => void = () => {};
@@ -619,6 +626,187 @@ describe("DaemonClient", () => {
}); });
}); });
test("auto-acks terminal stream chunks after delivery", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const seen: string[] = [];
const decoder = new TextDecoder();
const unsubscribe = client.onTerminalStreamData(7, (chunk) => {
seen.push(decoder.decode(chunk.data));
});
const payload = new TextEncoder().encode("hello");
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 7,
offset: 10,
payload,
})
);
expect(seen).toEqual(["hello"]);
expect(mock.sent).toHaveLength(1);
const ackBytes = asUint8Array(mock.sent[0]);
expect(ackBytes).not.toBeNull();
const ackFrame = decodeBinaryMuxFrame(ackBytes!);
expect(ackFrame).not.toBeNull();
expect(ackFrame!.channel).toBe(BinaryMuxChannel.Terminal);
expect(ackFrame!.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(ackFrame!.streamId).toBe(7);
expect(ackFrame!.offset).toBe(15);
unsubscribe();
});
test("acks buffered terminal chunks when handler is attached", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 19,
offset: 0,
payload: new TextEncoder().encode("buffered"),
})
);
expect(mock.sent).toHaveLength(0);
const seen: string[] = [];
client.onTerminalStreamData(19, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
});
expect(seen.join("")).toContain("buffered");
expect(mock.sent).toHaveLength(1);
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(ackFrame?.streamId).toBe(19);
expect(ackFrame?.offset).toBe(8);
});
test("cleans terminal stream local state on terminal_stream_exit", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const unsubscribe = client.onTerminalStreamData(23, () => {
// no-op
});
const internal = client as unknown as {
terminalStreamHandlers: Map<number, Set<(chunk: unknown) => void>>;
bufferedTerminalStreamChunks: Map<number, { chunks: unknown[]; bytes: number }>;
terminalStreamAckOffsets: Map<number, number>;
};
internal.bufferedTerminalStreamChunks.set(23, { chunks: [], bytes: 0 });
internal.terminalStreamAckOffsets.set(23, 100);
mock.triggerMessage(
wrapSessionMessage({
type: "terminal_stream_exit",
payload: {
streamId: 23,
terminalId: "term-1",
},
})
);
expect(internal.terminalStreamHandlers.has(23)).toBe(false);
expect(internal.bufferedTerminalStreamChunks.has(23)).toBe(false);
expect(internal.terminalStreamAckOffsets.has(23)).toBe(false);
unsubscribe();
});
test("cleans local stream state even when detach reports success=false", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
client.onTerminalStreamData(31, () => {
// no-op
});
const internal = client as unknown as {
bufferedTerminalStreamChunks: Map<number, { chunks: unknown[]; bytes: number }>;
terminalStreamAckOffsets: Map<number, number>;
};
internal.bufferedTerminalStreamChunks.set(31, { chunks: [], bytes: 0 });
internal.terminalStreamAckOffsets.set(31, 55);
const detachPromise = client.detachTerminalStream(31, "detach-31");
const detachRequest = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: { type: string; streamId: number; requestId: string };
};
expect(detachRequest.message.type).toBe("detach_terminal_stream_request");
expect(detachRequest.message.streamId).toBe(31);
mock.triggerMessage(
wrapSessionMessage({
type: "detach_terminal_stream_response",
payload: {
streamId: 31,
success: false,
requestId: "detach-31",
},
})
);
const payload = await detachPromise;
expect(payload.success).toBe(false);
expect(internal.bufferedTerminalStreamChunks.has(31)).toBe(false);
expect(internal.terminalStreamAckOffsets.has(31)).toBe(false);
});
test("parses canonical agent_stream tool_call payloads without crashing", async () => { test("parses canonical agent_stream tool_call payloads without crashing", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
+346 -6
View File
@@ -39,6 +39,8 @@ import type {
SubscribeTerminalResponse, SubscribeTerminalResponse,
TerminalOutput, TerminalOutput,
KillTerminalResponse, KillTerminalResponse,
AttachTerminalStreamResponse,
DetachTerminalStreamResponse,
TerminalInput, TerminalInput,
SessionInboundMessage, SessionInboundMessage,
SessionOutboundMessage, SessionOutboundMessage,
@@ -57,6 +59,19 @@ import {
type Transport as RelayTransport, type Transport as RelayTransport,
} from "@getpaseo/relay/e2ee"; } from "@getpaseo/relay/e2ee";
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js"; import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
import {
asUint8Array,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
BinaryMuxChannel,
TerminalBinaryFlags,
TerminalBinaryMessageType,
type BinaryMuxFrame,
} from "../shared/binary-mux.js";
import {
encodeTerminalKeyInput,
type TerminalKeyInput,
} from "../shared/terminal-key-input.js";
export interface Logger { export interface Logger {
debug(obj: object, msg?: string): void; debug(obj: object, msg?: string): void;
@@ -73,7 +88,7 @@ const consoleLogger: Logger = {
}; };
export type DaemonTransport = { export type DaemonTransport = {
send: (data: string) => void; send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void; close: (code?: number, reason?: string) => void;
onMessage: (handler: (data: unknown) => void) => () => void; onMessage: (handler: (data: unknown) => void) => () => void;
onOpen: (handler: () => void) => () => void; onOpen: (handler: () => void) => () => void;
@@ -93,8 +108,9 @@ export type WebSocketFactory = (
export type WebSocketLike = { export type WebSocketLike = {
readyState: number; readyState: number;
send: (data: string) => void; send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void; close: (code?: number, reason?: string) => void;
binaryType?: string;
on?: (event: string, listener: (...args: any[]) => void) => void; on?: (event: string, listener: (...args: any[]) => void) => void;
off?: (event: string, listener: (...args: any[]) => void) => void; off?: (event: string, listener: (...args: any[]) => void) => void;
removeListener?: (event: string, listener: (...args: any[]) => void) => void; removeListener?: (event: string, listener: (...args: any[]) => void) => void;
@@ -217,6 +233,24 @@ type CreateTerminalPayload = CreateTerminalResponse["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type TerminalOutputPayload = TerminalOutput["payload"]; type TerminalOutputPayload = TerminalOutput["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"];
type AttachTerminalStreamPayload = AttachTerminalStreamResponse["payload"];
type DetachTerminalStreamPayload = DetachTerminalStreamResponse["payload"];
export type TerminalStreamChunk = {
streamId: number;
offset: number;
endOffset: number;
replay: boolean;
data: Uint8Array;
};
type BufferedTerminalStreamQueue = {
chunks: TerminalStreamChunk[];
bytes: number;
};
const TERMINAL_STREAM_MAX_BUFFERED_CHUNKS = 2048;
const TERMINAL_STREAM_MAX_BUFFERED_BYTES = 2 * 1024 * 1024;
type AgentRefreshedStatusPayload = z.infer< type AgentRefreshedStatusPayload = z.infer<
typeof AgentRefreshedStatusPayloadSchema typeof AgentRefreshedStatusPayloadSchema
@@ -321,6 +355,12 @@ export class DaemonClient {
private logger: Logger; private logger: Logger;
private pendingSendQueue: PendingSend[] = []; private pendingSendQueue: PendingSend[] = [];
private relayClientId: string | null = null; private relayClientId: string | null = null;
private terminalStreamHandlers: Map<
number,
Set<(chunk: TerminalStreamChunk) => void>
> = new Map();
private bufferedTerminalStreamChunks: Map<number, BufferedTerminalStreamQueue> = new Map();
private terminalStreamAckOffsets: Map<number, number> = new Map();
constructor(private config: DaemonClientConfig) { constructor(private config: DaemonClientConfig) {
this.logger = config.logger ?? consoleLogger; this.logger = config.logger ?? consoleLogger;
@@ -538,6 +578,9 @@ export class DaemonClient {
} }
this.disposeTransport(1000, "Client closed"); this.disposeTransport(1000, "Client closed");
this.clearWaiters(new Error("Daemon client closed")); this.clearWaiters(new Error("Daemon client closed"));
this.terminalStreamHandlers.clear();
this.bufferedTerminalStreamChunks.clear();
this.terminalStreamAckOffsets.clear();
this.updateConnectionState({ this.updateConnectionState({
status: "disconnected", status: "disconnected",
reason: "client_closed", reason: "client_closed",
@@ -656,6 +699,23 @@ export class DaemonClient {
} }
} }
private sendBinaryFrame(frame: BinaryMuxFrame): void {
if (!this.transport || this.connectionState.status !== "connected") {
if (this.config.suppressSendErrors) {
return;
}
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
}
try {
this.transport.send(encodeBinaryMuxFrame(frame));
} catch (error) {
if (this.config.suppressSendErrors) {
return;
}
throw error instanceof Error ? error : new Error(String(error));
}
}
/** /**
* Send a session message for RPC methods that create waiters. * Send a session message for RPC methods that create waiters.
* If the connection is still being established ("connecting"), the message * If the connection is still being established ("connecting"), the message
@@ -2569,6 +2629,159 @@ export class DaemonClient {
}); });
} }
async attachTerminalStream(
terminalId: string,
options?: {
resumeOffset?: number;
rows?: number;
cols?: number;
},
requestId?: string
): Promise<AttachTerminalStreamPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "attach_terminal_stream_request",
terminalId,
requestId: resolvedRequestId,
...(options?.resumeOffset !== undefined ? { resumeOffset: options.resumeOffset } : {}),
...(options?.rows !== undefined ? { rows: options.rows } : {}),
...(options?.cols !== undefined ? { cols: options.cols } : {}),
});
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "attach_terminal_stream_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
});
}
async detachTerminalStream(
streamId: number,
requestId?: string
): Promise<DetachTerminalStreamPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "detach_terminal_stream_request",
streamId,
requestId: resolvedRequestId,
});
const payload = await this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "detach_terminal_stream_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
});
this.terminalStreamHandlers.delete(streamId);
this.bufferedTerminalStreamChunks.delete(streamId);
this.terminalStreamAckOffsets.delete(streamId);
return payload;
}
onTerminalStreamData(
streamId: number,
handler: (chunk: TerminalStreamChunk) => void
): () => void {
if (!this.terminalStreamHandlers.has(streamId)) {
this.terminalStreamHandlers.set(streamId, new Set());
}
const handlers = this.terminalStreamHandlers.get(streamId)!;
handlers.add(handler);
const buffered = this.bufferedTerminalStreamChunks.get(streamId);
if (buffered && buffered.chunks.length > 0) {
for (const chunk of buffered.chunks) {
try {
handler(chunk);
this.maybeAckTerminalStreamChunk(streamId, chunk.endOffset);
} catch {
// no-op
}
}
this.bufferedTerminalStreamChunks.delete(streamId);
}
return () => {
handlers.delete(handler);
if (handlers.size === 0) {
this.terminalStreamHandlers.delete(streamId);
}
};
}
async waitForTerminalStreamData(
streamId: number,
predicate: (chunk: TerminalStreamChunk) => boolean,
timeout = 5000
): Promise<TerminalStreamChunk> {
return new Promise<TerminalStreamChunk>((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
unsubscribe();
reject(new Error(`Timeout waiting for terminal stream data (${timeout}ms)`));
}, timeout);
const unsubscribe = this.onTerminalStreamData(streamId, (chunk) => {
if (!predicate(chunk)) {
return;
}
clearTimeout(timeoutHandle);
unsubscribe();
resolve(chunk);
});
});
}
sendTerminalStreamInput(streamId: number, data: string | Uint8Array): void {
const payload = typeof data === "string" ? encodeUtf8String(data) : data;
this.sendBinaryFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.InputUtf8,
streamId,
offset: 0,
payload,
});
}
sendTerminalStreamKey(streamId: number, input: TerminalKeyInput): void {
const encoded = encodeTerminalKeyInput(input);
if (!encoded) {
return;
}
this.sendTerminalStreamInput(streamId, encoded);
}
sendTerminalStreamAck(streamId: number, offset: number): void {
const normalizedOffset = Math.max(0, Math.floor(offset));
const previousAck = this.terminalStreamAckOffsets.get(streamId) ?? -1;
if (normalizedOffset > previousAck) {
this.terminalStreamAckOffsets.set(streamId, normalizedOffset);
}
this.sendBinaryFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.Ack,
streamId,
offset: normalizedOffset,
payload: new Uint8Array(0),
});
}
async waitForTerminalOutput( async waitForTerminalOutput(
terminalId: string, terminalId: string,
timeout = 5000 timeout = 5000
@@ -2628,6 +2841,14 @@ export class DaemonClient {
data && typeof data === "object" && "data" in data data && typeof data === "object" && "data" in data
? (data as { data: unknown }).data ? (data as { data: unknown }).data
: data; : data;
const rawBytes = asUint8Array(rawData);
if (rawBytes) {
const frame = decodeBinaryMuxFrame(rawBytes);
if (frame) {
this.handleBinaryFrame(frame);
return;
}
}
const payload = decodeMessageData(rawData); const payload = decodeMessageData(rawData);
if (!payload) { if (!payload) {
return; return;
@@ -2654,6 +2875,75 @@ export class DaemonClient {
this.handleSessionMessage(parsed.data.message); this.handleSessionMessage(parsed.data.message);
} }
private handleBinaryFrame(frame: BinaryMuxFrame): void {
if (
frame.channel === BinaryMuxChannel.Terminal &&
frame.messageType === TerminalBinaryMessageType.OutputUtf8
) {
const handlers = this.terminalStreamHandlers.get(frame.streamId);
const chunk: TerminalStreamChunk = {
streamId: frame.streamId,
offset: frame.offset,
endOffset: frame.offset + (frame.payload?.byteLength ?? 0),
replay: Boolean((frame.flags ?? 0) & TerminalBinaryFlags.Replay),
data: frame.payload ?? new Uint8Array(0),
};
if (!handlers || handlers.size === 0) {
const queue = this.bufferedTerminalStreamChunks.get(frame.streamId) ?? {
chunks: [],
bytes: 0,
};
queue.chunks.push(chunk);
queue.bytes += chunk.data.byteLength;
while (
queue.chunks.length > TERMINAL_STREAM_MAX_BUFFERED_CHUNKS ||
queue.bytes > TERMINAL_STREAM_MAX_BUFFERED_BYTES
) {
const removed = queue.chunks.shift();
if (!removed) {
break;
}
queue.bytes -= removed.data.byteLength;
if (queue.bytes < 0) {
queue.bytes = 0;
}
}
this.bufferedTerminalStreamChunks.set(frame.streamId, queue);
return;
}
let delivered = false;
for (const handler of handlers) {
try {
handler(chunk);
delivered = true;
} catch {
// no-op
}
}
if (delivered) {
this.maybeAckTerminalStreamChunk(frame.streamId, chunk.endOffset);
}
return;
}
}
private maybeAckTerminalStreamChunk(streamId: number, endOffset: number): void {
if (!Number.isFinite(endOffset) || endOffset < 0) {
return;
}
const normalizedEndOffset = Math.floor(endOffset);
const previousAck = this.terminalStreamAckOffsets.get(streamId) ?? -1;
if (normalizedEndOffset <= previousAck) {
return;
}
this.terminalStreamAckOffsets.set(streamId, normalizedEndOffset);
try {
this.sendTerminalStreamAck(streamId, normalizedEndOffset);
} catch {
// no-op
}
}
private updateConnectionState(next: ConnectionState): void { private updateConnectionState(next: ConnectionState): void {
this.connectionState = next; this.connectionState = next;
for (const listener of this.connectionListeners) { for (const listener of this.connectionListeners) {
@@ -2694,6 +2984,9 @@ export class DaemonClient {
// and responses from the previous connection will never arrive. // and responses from the previous connection will never arrive.
this.clearWaiters(new Error(reason ?? "Connection lost")); this.clearWaiters(new Error(reason ?? "Connection lost"));
this.rejectPendingSendQueue(new Error(reason ?? "Connection lost")); this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
this.terminalStreamHandlers.clear();
this.bufferedTerminalStreamChunks.clear();
this.terminalStreamAckOffsets.clear();
this.updateConnectionState({ this.updateConnectionState({
status: "disconnected", status: "disconnected",
@@ -2709,6 +3002,12 @@ export class DaemonClient {
} }
private handleSessionMessage(msg: SessionOutboundMessage): void { private handleSessionMessage(msg: SessionOutboundMessage): void {
if (msg.type === "terminal_stream_exit") {
this.terminalStreamHandlers.delete(msg.payload.streamId);
this.bufferedTerminalStreamChunks.delete(msg.payload.streamId);
this.terminalStreamAckOffsets.delete(msg.payload.streamId);
}
if (this.rawMessageListeners.size > 0) { if (this.rawMessageListeners.size > 0) {
for (const handler of this.rawMessageListeners) { for (const handler of this.rawMessageListeners) {
try { try {
@@ -2904,6 +3203,13 @@ function createWebSocketTransportFactory(
): DaemonTransportFactory { ): DaemonTransportFactory {
return ({ url, headers }) => { return ({ url, headers }) => {
const ws = factory(url, { headers }); const ws = factory(url, { headers });
if ("binaryType" in ws) {
try {
ws.binaryType = "arraybuffer";
} catch {
// no-op
}
}
return { return {
send: (data) => { send: (data) => {
if (typeof ws.readyState === "number" && ws.readyState !== 1) { if (typeof ws.readyState === "number" && ws.readyState !== 1) {
@@ -2997,16 +3303,23 @@ function createEncryptedTransport(
base.send(data); base.send(data);
return; return;
} }
if (ArrayBuffer.isView(data)) {
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const out = new Uint8Array(view.byteLength);
out.set(view);
base.send(out.buffer);
return;
}
if (data instanceof ArrayBuffer) { if (data instanceof ArrayBuffer) {
if (typeof TextDecoder !== "undefined") { if (typeof TextDecoder !== "undefined") {
base.send(new TextDecoder().decode(data)); base.send(data);
return; return;
} }
if (typeof Buffer !== "undefined") { if (typeof Buffer !== "undefined") {
base.send(Buffer.from(data).toString("utf8")); base.send(data);
return; return;
} }
base.send(String(data)); base.send(data);
return; return;
} }
base.send(String(data)); base.send(String(data));
@@ -3055,7 +3368,20 @@ function createEncryptedTransport(
if (!channel) { if (!channel) {
throw new Error("Encrypted channel not ready"); throw new Error("Encrypted channel not ready");
} }
void channel.send(data).catch((error) => { const outbound =
typeof data === "string"
? data
: data instanceof ArrayBuffer
? data
: ArrayBuffer.isView(data)
? (() => {
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const out = new Uint8Array(view.byteLength);
out.set(view);
return out.buffer;
})()
: String(data);
void channel.send(outbound).catch((error) => {
emitError(error); emitError(error);
}); });
}, },
@@ -3240,6 +3566,20 @@ function decodeMessageData(data: unknown): string | null {
return null; return null;
} }
function encodeUtf8String(value: string): Uint8Array {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(value);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(value, "utf8"));
}
const out = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
out[i] = value.charCodeAt(i) & 0xff;
}
return out;
}
function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionConfig { function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionConfig {
const { const {
config, config,
@@ -11,6 +11,9 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js"; import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js";
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25;
function createCapturingLogger() { function createCapturingLogger() {
const lines: string[] = []; const lines: string[] = [];
const stream = new Writable({ const stream = new Writable({
@@ -90,7 +93,40 @@ async function waitForServer(port: number, timeout = 15000): Promise<void> {
throw new Error(`Server did not start on port ${port} within ${timeout}ms`); throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
} }
describe("Relay transport (E2EE) - daemon E2E", () => { async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const url = buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${port}`,
serverId,
role: "server",
});
const opened = await new Promise<boolean>((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
ws.terminate();
resolve(false);
}, 5000);
ws.once("open", () => {
clearTimeout(timer);
ws.close(1000, "probe");
resolve(true);
});
ws.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
if (opened) {
return;
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(`Relay WebSocket endpoint not ready on port ${port} within ${timeout}ms`);
}
(shouldRunRelayE2e ? describe : describe.skip)("Relay transport (E2EE) - daemon E2E", () => {
let relayPort: number; let relayPort: number;
let relayProcess: ChildProcess | null = null; let relayProcess: ChildProcess | null = null;
@@ -99,7 +135,17 @@ describe("Relay transport (E2EE) - daemon E2E", () => {
const relayDir = path.resolve(process.cwd(), "../relay"); const relayDir = path.resolve(process.cwd(), "../relay");
relayProcess = spawn( relayProcess = spawn(
"npx", "npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)], [
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
{ {
cwd: relayDir, cwd: relayDir,
env: { ...process.env }, env: { ...process.env },
@@ -124,6 +170,7 @@ describe("Relay transport (E2EE) - daemon E2E", () => {
}); });
await waitForServer(relayPort, 30000); await waitForServer(relayPort, 30000);
await waitForRelayWebSocketReady(relayPort, 60000);
}; };
const stopRelay = async () => { const stopRelay = async () => {
@@ -233,7 +280,7 @@ describe("Relay transport (E2EE) - daemon E2E", () => {
await stopRelay(); await stopRelay();
} }
}, },
30000 90000
); );
test( test(
@@ -332,6 +379,6 @@ describe("Relay transport (E2EE) - daemon E2E", () => {
await stopRelay(); await stopRelay();
} }
}, },
45000 90000
); );
}); });
@@ -2,15 +2,46 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "fs"; import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import path from "path"; import path from "path";
import WebSocket from "ws";
import { import {
createDaemonTestContext, createDaemonTestContext,
type DaemonTestContext, type DaemonTestContext,
} from "../test-utils/index.js"; } from "../test-utils/index.js";
import {
BinaryMuxChannel,
TerminalBinaryMessageType,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from "../../shared/binary-mux.js";
const decoder = new TextDecoder();
function tmpCwd(): string { function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-terminal-e2e-")); return mkdtempSync(path.join(tmpdir(), "daemon-terminal-e2e-"));
} }
async function waitForCondition(
predicate: () => boolean,
timeoutMs: number,
intervalMs = 25
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
function percentile(values: number[], p: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
return sorted[index];
}
const shouldRun = !process.env.CI; const shouldRun = !process.env.CI;
(shouldRun ? describe : describe.skip)("daemon E2E terminal", () => { (shouldRun ? describe : describe.skip)("daemon E2E terminal", () => {
@@ -246,4 +277,286 @@ const shouldRun = !process.env.CI;
}, },
30000 30000
); );
test(
"streams terminal output over binary mux and supports modifier keys",
async () => {
const cwd = tmpCwd();
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
expect(attach.error).toBeNull();
expect(attach.streamId).toBeTypeOf("number");
const streamId = attach.streamId!;
let text = "";
const unsubscribe = ctx.client.onTerminalStreamData(streamId, (chunk) => {
text += decoder.decode(chunk.data, { stream: true });
});
ctx.client.sendTerminalStreamInput(streamId, "echo binary-stream\r");
await waitForCondition(() => text.includes("binary-stream"), 10000);
ctx.client.sendTerminalStreamInput(streamId, "cat -v\r");
await waitForCondition(() => text.includes("cat -v"), 10000);
// Ctrl+B is the default tmux prefix; cat -v should render it as ^B.
ctx.client.sendTerminalStreamKey(streamId, { key: "b", ctrl: true });
ctx.client.sendTerminalStreamInput(streamId, "\r");
await waitForCondition(() => text.includes("^B"), 10000);
// Stop cat
ctx.client.sendTerminalStreamKey(streamId, { key: "c", ctrl: true });
const detach = await ctx.client.detachTerminalStream(streamId);
expect(detach.success).toBe(true);
unsubscribe();
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"replays detached terminal output from resume offset (scrollback continuity)",
async () => {
const cwd = tmpCwd();
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
expect(attach.error).toBeNull();
const streamId = attach.streamId!;
let output = "";
let latestOffset = attach.currentOffset;
const unsubscribe = ctx.client.onTerminalStreamData(streamId, (chunk) => {
output += decoder.decode(chunk.data, { stream: true });
latestOffset = chunk.endOffset;
});
ctx.client.sendTerminalStreamInput(streamId, "echo before-detach\r");
await waitForCondition(() => output.includes("before-detach"), 10000);
const firstDetach = await ctx.client.detachTerminalStream(streamId);
expect(firstDetach.success).toBe(true);
unsubscribe();
// Terminal keeps running while hidden, stream is detached.
ctx.client.sendTerminalInput(terminalId, { type: "input", data: "echo while-detached\r" });
await new Promise((resolve) => setTimeout(resolve, 300));
const resumed = await ctx.client.attachTerminalStream(terminalId, {
resumeOffset: latestOffset,
});
expect(resumed.error).toBeNull();
expect(resumed.streamId).toBeTypeOf("number");
expect(resumed.reset).toBe(false);
let replayedText = "";
const resumedUnsub = ctx.client.onTerminalStreamData(resumed.streamId!, (chunk) => {
if (chunk.replay) {
replayedText += decoder.decode(chunk.data, { stream: true });
}
});
await waitForCondition(() => replayedText.includes("while-detached"), 10000);
const secondDetach = await ctx.client.detachTerminalStream(resumed.streamId!);
expect(secondDetach.success).toBe(true);
resumedUnsub();
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"applies stream backpressure window until client ack advances",
async () => {
const cwd = tmpCwd();
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const ws = new WebSocket(`ws://127.0.0.1:${ctx.daemon.port}/ws`);
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
const attachRequestId = `attach-${Date.now()}`;
const detachRequestId = `detach-${Date.now()}`;
let streamId: number | null = null;
let latestEndOffset = 0;
let outputBytes = 0;
const streamReady = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timed out waiting for attach_terminal_stream_response"));
}, 10000);
ws.on("message", (raw) => {
if (Array.isArray(raw)) {
raw = Buffer.concat(raw.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part))));
}
if (typeof raw !== "string" && !Buffer.isBuffer(raw)) {
return;
}
const text = typeof raw === "string" ? raw : raw.toString("utf8");
try {
const parsed = JSON.parse(text) as {
type?: string;
message?: {
type?: string;
payload?: { streamId?: number | null; requestId?: string };
};
};
if (
parsed.type === "session" &&
parsed.message?.type === "attach_terminal_stream_response" &&
parsed.message.payload?.requestId === attachRequestId
) {
const nextStreamId = parsed.message.payload.streamId;
if (typeof nextStreamId === "number") {
streamId = nextStreamId;
clearTimeout(timeout);
resolve();
}
}
} catch {
// Ignore non-JSON payloads (binary mux frames).
}
});
});
ws.on("message", (raw) => {
if (typeof raw === "string") {
return;
}
if (Array.isArray(raw)) {
raw = Buffer.concat(raw.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part))));
}
const bytes = Buffer.isBuffer(raw)
? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
: new Uint8Array(raw as ArrayBuffer);
const frame = decodeBinaryMuxFrame(bytes);
if (
!frame ||
frame.channel !== BinaryMuxChannel.Terminal ||
frame.messageType !== TerminalBinaryMessageType.OutputUtf8
) {
return;
}
const chunkBytes = frame.payload?.byteLength ?? 0;
outputBytes += chunkBytes;
latestEndOffset = frame.offset + chunkBytes;
});
ws.send(
JSON.stringify({
type: "session",
message: {
type: "attach_terminal_stream_request",
terminalId,
requestId: attachRequestId,
},
})
);
await streamReady;
expect(streamId).toBeTypeOf("number");
ws.send(
JSON.stringify({
type: "session",
message: {
type: "terminal_input",
terminalId,
message: {
type: "input",
data: "head -c 1048576 /dev/zero | tr '\\0' 'A'\r",
},
},
})
);
await waitForCondition(() => outputBytes > 0, 10000);
await new Promise((resolve) => setTimeout(resolve, 800));
const beforeAckBytes = outputBytes;
expect(beforeAckBytes).toBeGreaterThan(0);
expect(beforeAckBytes).toBeLessThan(320 * 1024);
expect(latestEndOffset).toBeGreaterThan(0);
ws.send(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.Ack,
streamId: streamId!,
offset: latestEndOffset,
payload: new Uint8Array(0),
})
);
await waitForCondition(() => outputBytes > beforeAckBytes, 10000);
ws.send(
JSON.stringify({
type: "session",
message: {
type: "detach_terminal_stream_request",
streamId,
requestId: detachRequestId,
},
})
);
ws.close();
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"measures local terminal round-trip latency via daemon client stream",
async () => {
const cwd = tmpCwd();
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const attach = await ctx.client.attachTerminalStream(terminalId);
expect(attach.error).toBeNull();
const streamId = attach.streamId!;
let output = "";
const unsubscribe = ctx.client.onTerminalStreamData(streamId, (chunk) => {
output += decoder.decode(chunk.data, { stream: true });
});
const samplesMs: number[] = [];
const iterations = 8;
for (let i = 0; i < iterations; i++) {
const marker = `PASEO_LAT_${i}_${Date.now()}`;
const start = performance.now();
ctx.client.sendTerminalStreamInput(streamId, `echo ${marker}\r`);
await waitForCondition(() => output.includes(marker), 10000);
samplesMs.push(performance.now() - start);
}
const p50 = percentile(samplesMs, 50);
const p95 = percentile(samplesMs, 95);
expect(samplesMs).toHaveLength(iterations);
// Localhost budget should be tight; keep margin for test noise.
expect(p95).toBeLessThan(350);
// Emit measurements for diagnosis when this test regresses.
console.log(
`[terminal-latency] samples=${samplesMs.map((n) => n.toFixed(1)).join(",")} p50=${p50.toFixed(1)}ms p95=${p95.toFixed(1)}ms`
);
const detach = await ctx.client.detachTerminalStream(streamId);
expect(detach.success).toBe(true);
unsubscribe();
rmSync(cwd, { recursive: true, force: true });
},
30000
);
}); });
+15 -2
View File
@@ -25,7 +25,7 @@ export type RelayTransportController = {
type RelaySocketLike = { type RelaySocketLike = {
readyState: number; readyState: number;
send: (data: string) => void; send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void; close: (code?: number, reason?: string) => void;
on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void; on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void;
once: (event: "close" | "error", listener: (...args: any[]) => void) => void; once: (event: "close" | "error", listener: (...args: any[]) => void) => void;
@@ -436,7 +436,20 @@ function createEncryptedSocket(
return readyState; return readyState;
}, },
send: (data) => { send: (data) => {
void channel.send(data).catch((error) => { const outbound =
typeof data === "string"
? data
: data instanceof ArrayBuffer
? data
: ArrayBuffer.isView(data)
? (() => {
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const out = new Uint8Array(view.byteLength);
out.set(view);
return out.buffer;
})()
: String(data);
void channel.send(outbound).catch((error) => {
emitter.emit("error", error); emitter.emit("error", error);
}); });
}, },
+401
View File
@@ -20,12 +20,20 @@ import {
type UnsubscribeTerminalRequest, type UnsubscribeTerminalRequest,
type TerminalInput, type TerminalInput,
type KillTerminalRequest, type KillTerminalRequest,
type AttachTerminalStreamRequest,
type DetachTerminalStreamRequest,
type SubscribeCheckoutDiffRequest, type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest,
type ProjectCheckoutLitePayload, type ProjectCheckoutLitePayload,
type ProjectPlacementPayload, type ProjectPlacementPayload,
} from "./messages.js"; } from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import {
BinaryMuxChannel,
TerminalBinaryFlags,
TerminalBinaryMessageType,
type BinaryMuxFrame,
} from "../shared/binary-mux.js";
import { TTSManager } from "./agent/tts-manager.js"; import { TTSManager } from "./agent/tts-manager.js";
import { STTManager } from "./agent/stt-manager.js"; import { STTManager } from "./agent/stt-manager.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
@@ -137,6 +145,9 @@ const PROJECT_PLACEMENT_CACHE_TTL_MS = 10_000;
const MAX_AGENTS_PER_PROJECT = 5; const MAX_AGENTS_PER_PROJECT = 5;
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000; const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024;
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048;
/** /**
* Default model used for auto-generating commit messages and PR descriptions. * Default model used for auto-generating commit messages and PR descriptions.
@@ -255,6 +266,13 @@ type CheckoutErrorPayload = {
message: string; message: string;
}; };
type TerminalStreamPendingChunk = {
data: string;
startOffset: number;
endOffset: number;
replay: boolean;
};
const PCM_SAMPLE_RATE = 16000; const PCM_SAMPLE_RATE = 16000;
const PCM_CHANNELS = 1; const PCM_CHANNELS = 1;
const PCM_BITS_PER_SAMPLE = 16; const PCM_BITS_PER_SAMPLE = 16;
@@ -297,6 +315,7 @@ type VoiceTranscriptionResultPayload = {
export type SessionOptions = { export type SessionOptions = {
clientId: string; clientId: string;
onMessage: (msg: SessionOutboundMessage) => void; onMessage: (msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: BinaryMuxFrame) => void;
logger: pino.Logger; logger: pino.Logger;
downloadTokenStore: DownloadTokenStore; downloadTokenStore: DownloadTokenStore;
pushTokenStore: PushTokenStore; pushTokenStore: PushTokenStore;
@@ -409,6 +428,7 @@ export class Session {
private readonly clientId: string; private readonly clientId: string;
private readonly sessionId: string; private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void; private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null;
private readonly sessionLogger: pino.Logger; private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string; private readonly paseoHome: string;
@@ -483,6 +503,19 @@ export class Session {
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private terminalSubscriptions: Map<string, () => void> = new Map(); private terminalSubscriptions: Map<string, () => void> = new Map();
private readonly terminalStreams = new Map<
number,
{
terminalId: string;
unsubscribe: () => void;
lastOutputOffset: number;
lastAckOffset: number;
pendingChunks: TerminalStreamPendingChunk[];
pendingBytes: number;
}
>();
private readonly terminalStreamByTerminalId = new Map<string, number>();
private nextTerminalStreamId = 1;
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>(); private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>();
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>(); private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>();
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
@@ -508,6 +541,7 @@ export class Session {
const { const {
clientId, clientId,
onMessage, onMessage,
onBinaryMessage,
logger, logger,
downloadTokenStore, downloadTokenStore,
pushTokenStore, pushTokenStore,
@@ -526,6 +560,7 @@ export class Session {
this.clientId = clientId; this.clientId = clientId;
this.sessionId = uuidv4(); this.sessionId = uuidv4();
this.onMessage = onMessage; this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null;
this.downloadTokenStore = downloadTokenStore; this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore; this.pushTokenStore = pushTokenStore;
this.paseoHome = paseoHome; this.paseoHome = paseoHome;
@@ -1367,6 +1402,14 @@ export class Session {
case "kill_terminal_request": case "kill_terminal_request":
await this.handleKillTerminalRequest(msg); await this.handleKillTerminalRequest(msg);
break; break;
case "attach_terminal_stream_request":
await this.handleAttachTerminalStreamRequest(msg);
break;
case "detach_terminal_stream_request":
this.handleDetachTerminalStreamRequest(msg);
break;
} }
} catch (error: any) { } catch (error: any) {
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
@@ -1404,6 +1447,72 @@ export class Session {
} }
} }
public handleBinaryFrame(frame: BinaryMuxFrame): void {
switch (frame.channel) {
case BinaryMuxChannel.Terminal:
this.handleTerminalBinaryFrame(frame);
break;
default:
this.sessionLogger.warn(
{ channel: frame.channel, messageType: frame.messageType },
"Unhandled binary mux channel"
);
break;
}
}
private handleTerminalBinaryFrame(frame: BinaryMuxFrame): void {
if (frame.messageType === TerminalBinaryMessageType.InputUtf8) {
const binding = this.terminalStreams.get(frame.streamId);
if (!binding) {
this.sessionLogger.warn({ streamId: frame.streamId }, "Terminal stream not found for input");
return;
}
if (!this.terminalManager) {
return;
}
const session = this.terminalManager.getTerminal(binding.terminalId);
if (!session) {
this.detachTerminalStream(frame.streamId, { emitExit: true });
return;
}
const payload = frame.payload ?? new Uint8Array(0);
if (payload.byteLength === 0) {
return;
}
const text = Buffer.from(payload).toString("utf8");
if (!text) {
return;
}
session.send({ type: "input", data: text });
return;
}
if (frame.messageType === TerminalBinaryMessageType.Ack) {
const binding = this.terminalStreams.get(frame.streamId);
if (binding) {
if (!Number.isFinite(frame.offset) || frame.offset < 0) {
return;
}
const nextAckOffset = Math.max(
binding.lastAckOffset,
Math.min(Math.floor(frame.offset), binding.lastOutputOffset)
);
if (nextAckOffset > binding.lastAckOffset) {
binding.lastAckOffset = nextAckOffset;
this.flushPendingTerminalStreamChunks(frame.streamId, binding);
}
}
return;
}
this.sessionLogger.warn(
{ streamId: frame.streamId, messageType: frame.messageType },
"Unhandled terminal binary frame"
);
}
private async handleRestartServerRequest( private async handleRestartServerRequest(
requestId: string, requestId: string,
reason?: string reason?: string
@@ -5642,6 +5751,17 @@ export class Session {
this.onMessage(msg); this.onMessage(msg);
} }
private emitBinary(frame: BinaryMuxFrame): void {
if (!this.onBinaryMessage) {
return;
}
try {
this.onBinaryMessage(frame);
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to emit binary frame");
}
}
/** /**
* Clean up session resources * Clean up session resources
*/ */
@@ -5694,6 +5814,7 @@ export class Session {
unsubscribe(); unsubscribe();
} }
this.terminalSubscriptions.clear(); this.terminalSubscriptions.clear();
this.detachAllTerminalStreams({ emitExit: false });
for (const target of this.checkoutDiffTargets.values()) { for (const target of this.checkoutDiffTargets.values()) {
this.closeCheckoutDiffWatchTarget(target); this.closeCheckoutDiffWatchTarget(target);
@@ -5883,6 +6004,11 @@ export class Session {
this.terminalSubscriptions.delete(msg.terminalId); this.terminalSubscriptions.delete(msg.terminalId);
} }
const streamId = this.terminalStreamByTerminalId.get(msg.terminalId);
if (typeof streamId === "number") {
this.detachTerminalStream(streamId, { emitExit: true });
}
this.terminalManager.killTerminal(msg.terminalId); this.terminalManager.killTerminal(msg.terminalId);
this.emit({ this.emit({
type: "kill_terminal_response", type: "kill_terminal_response",
@@ -5893,4 +6019,279 @@ export class Session {
}, },
}); });
} }
private async handleAttachTerminalStreamRequest(
msg: AttachTerminalStreamRequest
): Promise<void> {
if (!this.terminalManager || !this.onBinaryMessage) {
this.emit({
type: "attach_terminal_stream_response",
payload: {
terminalId: msg.terminalId,
streamId: null,
replayedFrom: 0,
currentOffset: 0,
earliestAvailableOffset: 0,
reset: true,
error: "Terminal streaming not available",
requestId: msg.requestId,
},
});
return;
}
const session = this.terminalManager.getTerminal(msg.terminalId);
if (!session) {
this.emit({
type: "attach_terminal_stream_response",
payload: {
terminalId: msg.terminalId,
streamId: null,
replayedFrom: 0,
currentOffset: 0,
earliestAvailableOffset: 0,
reset: true,
error: "Terminal not found",
requestId: msg.requestId,
},
});
return;
}
if (msg.rows || msg.cols) {
const state = session.getState();
session.send({
type: "resize",
rows: msg.rows ?? state.rows,
cols: msg.cols ?? state.cols,
});
}
const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId);
if (typeof existingStreamId === "number") {
this.detachTerminalStream(existingStreamId, { emitExit: false });
}
const streamId = this.allocateTerminalStreamId();
const initialOffset = Math.max(0, Math.floor(msg.resumeOffset ?? 0));
const binding: {
terminalId: string;
unsubscribe: () => void;
lastOutputOffset: number;
lastAckOffset: number;
pendingChunks: TerminalStreamPendingChunk[];
pendingBytes: number;
} = {
terminalId: msg.terminalId,
unsubscribe: () => {},
lastOutputOffset: initialOffset,
lastAckOffset: initialOffset,
pendingChunks: [],
pendingBytes: 0,
};
this.terminalStreams.set(streamId, binding);
this.terminalStreamByTerminalId.set(msg.terminalId, streamId);
let rawSub;
try {
rawSub = session.subscribeRaw(
(chunk) => {
const currentBinding = this.terminalStreams.get(streamId);
if (!currentBinding) {
return;
}
this.enqueueOrEmitTerminalStreamChunk(streamId, currentBinding, {
data: chunk.data,
startOffset: chunk.startOffset,
endOffset: chunk.endOffset,
replay: chunk.replay,
});
},
{ fromOffset: msg.resumeOffset ?? 0 }
);
} catch (error) {
this.terminalStreams.delete(streamId);
this.terminalStreamByTerminalId.delete(msg.terminalId);
throw error;
}
binding.unsubscribe = rawSub.unsubscribe;
binding.lastAckOffset = rawSub.replayedFrom;
if (binding.lastOutputOffset < rawSub.replayedFrom) {
binding.lastOutputOffset = rawSub.replayedFrom;
}
this.flushPendingTerminalStreamChunks(streamId, binding);
this.emit({
type: "attach_terminal_stream_response",
payload: {
terminalId: msg.terminalId,
streamId,
replayedFrom: rawSub.replayedFrom,
currentOffset: rawSub.currentOffset,
earliestAvailableOffset: rawSub.earliestAvailableOffset,
reset: rawSub.reset,
error: null,
requestId: msg.requestId,
},
});
}
private getTerminalStreamChunkByteLength(chunk: TerminalStreamPendingChunk): number {
return Math.max(0, chunk.endOffset - chunk.startOffset);
}
private canEmitTerminalStreamChunk(
binding: {
lastAckOffset: number;
},
chunk: TerminalStreamPendingChunk
): boolean {
return chunk.startOffset < binding.lastAckOffset + TERMINAL_STREAM_WINDOW_BYTES;
}
private emitTerminalStreamChunk(
streamId: number,
binding: {
lastOutputOffset: number;
},
chunk: TerminalStreamPendingChunk
): void {
const payload = new Uint8Array(Buffer.from(chunk.data, "utf8"));
this.emitBinary({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId,
offset: chunk.startOffset,
flags: chunk.replay ? TerminalBinaryFlags.Replay : 0,
payload,
});
binding.lastOutputOffset = chunk.endOffset;
}
private enqueueOrEmitTerminalStreamChunk(
streamId: number,
binding: {
lastAckOffset: number;
lastOutputOffset: number;
pendingChunks: TerminalStreamPendingChunk[];
pendingBytes: number;
},
chunk: TerminalStreamPendingChunk
): void {
const chunkBytes = this.getTerminalStreamChunkByteLength(chunk);
if (binding.pendingChunks.length > 0 || !this.canEmitTerminalStreamChunk(binding, chunk)) {
if (
binding.pendingChunks.length >= TERMINAL_STREAM_MAX_PENDING_CHUNKS ||
binding.pendingBytes + chunkBytes > TERMINAL_STREAM_MAX_PENDING_BYTES
) {
this.sessionLogger.warn(
{
streamId,
pendingChunks: binding.pendingChunks.length,
pendingBytes: binding.pendingBytes,
chunkBytes,
},
"Terminal stream pending buffer overflow; closing stream"
);
this.detachTerminalStream(streamId, { emitExit: true });
return;
}
binding.pendingChunks.push(chunk);
binding.pendingBytes += chunkBytes;
return;
}
this.emitTerminalStreamChunk(streamId, binding, chunk);
}
private flushPendingTerminalStreamChunks(
streamId: number,
binding: {
lastAckOffset: number;
lastOutputOffset: number;
pendingChunks: TerminalStreamPendingChunk[];
pendingBytes: number;
}
): void {
while (binding.pendingChunks.length > 0) {
const next = binding.pendingChunks[0];
if (!next || !this.canEmitTerminalStreamChunk(binding, next)) {
break;
}
binding.pendingChunks.shift();
binding.pendingBytes -= this.getTerminalStreamChunkByteLength(next);
if (binding.pendingBytes < 0) {
binding.pendingBytes = 0;
}
this.emitTerminalStreamChunk(streamId, binding, next);
}
}
private handleDetachTerminalStreamRequest(msg: DetachTerminalStreamRequest): void {
const success = this.detachTerminalStream(msg.streamId, { emitExit: false });
this.emit({
type: "detach_terminal_stream_response",
payload: {
streamId: msg.streamId,
success,
requestId: msg.requestId,
},
});
}
private detachAllTerminalStreams(options?: { emitExit: boolean }): void {
for (const streamId of Array.from(this.terminalStreams.keys())) {
this.detachTerminalStream(streamId, options);
}
}
private detachTerminalStream(
streamId: number,
options?: { emitExit: boolean }
): boolean {
const binding = this.terminalStreams.get(streamId);
if (!binding) {
return false;
}
try {
binding.unsubscribe();
} catch (error) {
this.sessionLogger.warn({ err: error, streamId }, "Failed to unsubscribe terminal stream");
}
this.terminalStreams.delete(streamId);
if (this.terminalStreamByTerminalId.get(binding.terminalId) === streamId) {
this.terminalStreamByTerminalId.delete(binding.terminalId);
}
if (options?.emitExit) {
this.emit({
type: "terminal_stream_exit",
payload: {
streamId,
terminalId: binding.terminalId,
},
});
}
return true;
}
private allocateTerminalStreamId(): number {
let attempts = 0;
while (attempts < 0xffffffff) {
const candidate = this.nextTerminalStreamId >>> 0;
this.nextTerminalStreamId = ((this.nextTerminalStreamId + 1) & 0xffffffff) >>> 0;
if (candidate === 0) {
attempts += 1;
continue;
}
if (!this.terminalStreams.has(candidate)) {
return candidate;
}
attempts += 1;
}
throw new Error("Unable to allocate terminal stream id");
}
} }
@@ -1,4 +1,11 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
BinaryMuxChannel,
TerminalBinaryMessageType,
asUint8Array,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from "../shared/binary-mux.js";
const wsModuleMock = vi.hoisted(() => { const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer { class MockWebSocketServer {
@@ -28,6 +35,7 @@ const sessionMock = vi.hoisted(() => {
class MockSession { class MockSession {
cleanup = vi.fn(async () => {}); cleanup = vi.fn(async () => {});
handleMessage = vi.fn(async () => {}); handleMessage = vi.fn(async () => {});
handleBinaryFrame = vi.fn((_frame: unknown) => {});
getClientActivity = vi.fn(() => null); getClientActivity = vi.fn(() => null);
readonly args: Record<string, unknown>; readonly args: Record<string, unknown>;
@@ -71,7 +79,7 @@ import {
class MockSocket { class MockSocket {
readyState = 1; readyState = 1;
sent: string[] = []; sent: unknown[] = [];
private listeners = new Map<string, Array<(...args: any[]) => void>>(); private listeners = new Map<string, Array<(...args: any[]) => void>>();
on(event: "message" | "close" | "error", listener: (...args: any[]) => void): void { on(event: "message" | "close" | "error", listener: (...args: any[]) => void): void {
@@ -88,7 +96,7 @@ class MockSocket {
this.on(event, wrapped); this.on(event, wrapped);
} }
send(data: string): void { send(data: unknown): void {
this.sent.push(data); this.sent.push(data);
} }
@@ -196,4 +204,90 @@ describe("relay external socket reconnect behavior", () => {
await server.close(); await server.close();
}); });
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-binary-inbound",
};
const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata);
expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!;
socket.emit(
"message",
Buffer.from(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.InputUtf8,
streamId: 9,
offset: 0,
payload: new TextEncoder().encode("ls\r"),
})
)
);
await Promise.resolve();
expect(session.handleBinaryFrame).toHaveBeenCalledTimes(1);
const frame = session.handleBinaryFrame.mock.calls[0]?.[0] as {
channel: number;
messageType: number;
streamId: number;
offset: number;
};
expect(frame.channel).toBe(BinaryMuxChannel.Terminal);
expect(frame.messageType).toBe(TerminalBinaryMessageType.InputUtf8);
expect(frame.streamId).toBe(9);
expect(frame.offset).toBe(0);
await server.close();
});
test("sends outbound binary mux frames from session over websocket", async () => {
const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "relay:client-binary-outbound",
};
const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata);
expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!;
const onBinaryMessage = session.args.onBinaryMessage as
| ((frame: {
channel: number;
messageType: number;
streamId: number;
offset: number;
payload?: Uint8Array;
}) => void)
| undefined;
expect(onBinaryMessage).toBeTypeOf("function");
onBinaryMessage?.({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 11,
offset: 42,
payload: new TextEncoder().encode("ok"),
});
expect(socket.sent).toHaveLength(2);
const binaryPayload = asUint8Array(socket.sent[1]);
expect(binaryPayload).not.toBeNull();
const frame = decodeBinaryMuxFrame(binaryPayload!);
expect(frame).not.toBeNull();
expect(frame!.channel).toBe(BinaryMuxChannel.Terminal);
expect(frame!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8);
expect(frame!.streamId).toBe(11);
expect(frame!.offset).toBe(42);
expect(new TextDecoder().decode(frame!.payload ?? new Uint8Array())).toBe("ok");
await server.close();
});
}); });
+32 -1
View File
@@ -13,6 +13,11 @@ import {
type WSOutboundMessage, type WSOutboundMessage,
wrapSessionMessage, wrapSessionMessage,
} from "./messages.js"; } from "./messages.js";
import {
asUint8Array,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from "../shared/binary-mux.js";
import type { AllowedHostsConfig } from "./allowed-hosts.js"; import type { AllowedHostsConfig } from "./allowed-hosts.js";
import { isHostAllowed } from "./allowed-hosts.js"; import { isHostAllowed } from "./allowed-hosts.js";
import { Session } from "./session.js"; import { Session } from "./session.js";
@@ -54,7 +59,7 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe
type WebSocketLike = { type WebSocketLike = {
readyState: number; readyState: number;
send: (data: string) => void; send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void; close: (code?: number, reason?: string) => void;
on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void; on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void;
once: (event: "close" | "error", listener: (...args: any[]) => void) => void; once: (event: "close" | "error", listener: (...args: any[]) => void) => void;
@@ -253,6 +258,16 @@ export class VoiceAssistantWebSocketServer {
} }
} }
private sendBinaryToClient(
ws: WebSocketLike,
frame: Parameters<typeof encodeBinaryMuxFrame>[0]
): void {
if (ws.readyState !== 1) {
return;
}
ws.send(encodeBinaryMuxFrame(frame));
}
private async attachSocket( private async attachSocket(
ws: WebSocketLike, ws: WebSocketLike,
_request?: unknown, _request?: unknown,
@@ -301,6 +316,9 @@ export class VoiceAssistantWebSocketServer {
onMessage: (msg) => { onMessage: (msg) => {
this.sendToClient(socketRef.current, wrapSessionMessage(msg)); this.sendToClient(socketRef.current, wrapSessionMessage(msg));
}, },
onBinaryMessage: (frame) => {
this.sendBinaryToClient(socketRef.current, frame);
},
logger: connectionLogger.child({ module: "session" }), logger: connectionLogger.child({ module: "session" }),
downloadTokenStore: this.downloadTokenStore, downloadTokenStore: this.downloadTokenStore,
pushTokenStore: this.pushTokenStore, pushTokenStore: this.pushTokenStore,
@@ -481,6 +499,19 @@ export class VoiceAssistantWebSocketServer {
): Promise<void> { ): Promise<void> {
try { try {
const buffer = bufferFromWsData(data); const buffer = bufferFromWsData(data);
const asBytes = asUint8Array(buffer);
if (asBytes) {
const frame = decodeBinaryMuxFrame(asBytes);
if (frame) {
const connection = this.sessions.get(ws);
if (!connection) {
this.logger.error("No session found for client");
return;
}
connection.session.handleBinaryFrame(frame);
return;
}
}
const parsed = JSON.parse(buffer.toString()); const parsed = JSON.parse(buffer.toString());
const parsedMessage = WSInboundMessageSchema.safeParse(parsed); const parsedMessage = WSInboundMessageSchema.safeParse(parsed);
if (!parsedMessage.success) { if (!parsedMessage.success) {
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
BinaryMuxChannel,
TerminalBinaryFlags,
TerminalBinaryMessageType,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from "./binary-mux.js";
describe("binary mux frame codec", () => {
it("encodes and decodes round trip", () => {
const payload = new TextEncoder().encode("hello");
const encoded = encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 42,
offset: 1234,
flags: TerminalBinaryFlags.Replay,
payload,
});
const decoded = decodeBinaryMuxFrame(encoded);
expect(decoded).not.toBeNull();
expect(decoded!.channel).toBe(BinaryMuxChannel.Terminal);
expect(decoded!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8);
expect(decoded!.streamId).toBe(42);
expect(decoded!.offset).toBe(1234);
expect(decoded!.flags).toBe(TerminalBinaryFlags.Replay);
expect(Array.from(decoded!.payload ?? [])).toEqual(Array.from(payload));
});
it("rejects malformed frame payload length", () => {
const encoded = encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.InputUtf8,
streamId: 1,
offset: 0,
payload: new Uint8Array([1, 2, 3]),
});
const tampered = encoded.slice(0, encoded.byteLength - 1);
expect(decodeBinaryMuxFrame(tampered)).toBeNull();
});
});
+120
View File
@@ -0,0 +1,120 @@
const MUX_MAGIC_1 = 0x50; // 'P'
const MUX_MAGIC_2 = 0x58; // 'X'
const MUX_VERSION = 1;
const HEADER_SIZE = 24;
const UINT32_MAX = 0xffffffff;
const TWO_POW_32 = 0x1_0000_0000;
export const BinaryMuxConstants = {
magic1: MUX_MAGIC_1,
magic2: MUX_MAGIC_2,
version: MUX_VERSION,
headerSize: HEADER_SIZE,
} as const;
export const enum BinaryMuxChannel {
Terminal = 1,
FileTransfer = 2,
}
export const enum TerminalBinaryMessageType {
InputUtf8 = 1,
OutputUtf8 = 2,
Ack = 3,
}
export const enum TerminalBinaryFlags {
Replay = 1,
}
export interface BinaryMuxFrame {
channel: number;
messageType: number;
streamId: number;
offset: number;
flags?: number;
payload?: Uint8Array;
}
export function asUint8Array(data: unknown): Uint8Array | null {
if (data instanceof Uint8Array) {
return data;
}
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
return null;
}
export function isLikelyBinaryMuxFrame(data: Uint8Array): boolean {
if (data.byteLength < HEADER_SIZE) {
return false;
}
return data[0] === MUX_MAGIC_1 && data[1] === MUX_MAGIC_2 && data[2] === MUX_VERSION;
}
export function encodeBinaryMuxFrame(frame: BinaryMuxFrame): Uint8Array {
const payload = frame.payload ?? new Uint8Array(0);
const out = new Uint8Array(HEADER_SIZE + payload.byteLength);
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
const flags = frame.flags ?? 0;
if (!Number.isFinite(frame.streamId) || frame.streamId < 0 || frame.streamId > UINT32_MAX) {
throw new Error(`Invalid streamId: ${frame.streamId}`);
}
if (!Number.isFinite(frame.offset) || frame.offset < 0) {
throw new Error(`Invalid offset: ${frame.offset}`);
}
const offsetHi = Math.floor(frame.offset / TWO_POW_32);
const offsetLo = frame.offset >>> 0;
if (offsetHi > UINT32_MAX) {
throw new Error(`Offset too large: ${frame.offset}`);
}
view.setUint8(0, MUX_MAGIC_1);
view.setUint8(1, MUX_MAGIC_2);
view.setUint8(2, MUX_VERSION);
view.setUint8(3, frame.channel & 0xff);
view.setUint8(4, frame.messageType & 0xff);
view.setUint8(5, flags & 0xff);
view.setUint8(6, 0);
view.setUint8(7, 0);
view.setUint32(8, frame.streamId >>> 0);
view.setUint32(12, offsetHi >>> 0);
view.setUint32(16, offsetLo);
view.setUint32(20, payload.byteLength >>> 0);
out.set(payload, HEADER_SIZE);
return out;
}
export function decodeBinaryMuxFrame(data: Uint8Array): BinaryMuxFrame | null {
if (!isLikelyBinaryMuxFrame(data)) {
return null;
}
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const payloadLength = view.getUint32(20);
if (payloadLength !== data.byteLength - HEADER_SIZE) {
return null;
}
const offsetHi = view.getUint32(12);
const offsetLo = view.getUint32(16);
const offset = offsetHi * TWO_POW_32 + offsetLo;
return {
channel: view.getUint8(3),
messageType: view.getUint8(4),
flags: view.getUint8(5),
streamId: view.getUint32(8),
offset,
payload: data.subarray(HEADER_SIZE),
};
}
+56
View File
@@ -980,6 +980,21 @@ export const KillTerminalRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const AttachTerminalStreamRequestSchema = z.object({
type: z.literal("attach_terminal_stream_request"),
terminalId: z.string(),
resumeOffset: z.number().int().nonnegative().optional(),
rows: z.number().int().positive().optional(),
cols: z.number().int().positive().optional(),
requestId: z.string(),
});
export const DetachTerminalStreamRequestSchema = z.object({
type: z.literal("detach_terminal_stream_request"),
streamId: z.number().int().nonnegative(),
requestId: z.string(),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
VoiceAudioChunkMessageSchema, VoiceAudioChunkMessageSchema,
AbortRequestMessageSchema, AbortRequestMessageSchema,
@@ -1041,6 +1056,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UnsubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema,
TerminalInputSchema, TerminalInputSchema,
KillTerminalRequestSchema, KillTerminalRequestSchema,
AttachTerminalStreamRequestSchema,
DetachTerminalStreamRequestSchema,
]); ]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>; export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -1819,6 +1836,37 @@ export const KillTerminalResponseSchema = z.object({
}), }),
}); });
export const AttachTerminalStreamResponseSchema = z.object({
type: z.literal("attach_terminal_stream_response"),
payload: z.object({
terminalId: z.string(),
streamId: z.number().int().nonnegative().nullable(),
replayedFrom: z.number().int().nonnegative(),
currentOffset: z.number().int().nonnegative(),
earliestAvailableOffset: z.number().int().nonnegative(),
reset: z.boolean(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
export const DetachTerminalStreamResponseSchema = z.object({
type: z.literal("detach_terminal_stream_response"),
payload: z.object({
streamId: z.number().int().nonnegative(),
success: z.boolean(),
requestId: z.string(),
}),
});
export const TerminalStreamExitSchema = z.object({
type: z.literal("terminal_stream_exit"),
payload: z.object({
streamId: z.number().int().nonnegative(),
terminalId: z.string(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema, ActivityLogMessageSchema,
AssistantChunkMessageSchema, AssistantChunkMessageSchema,
@@ -1879,6 +1927,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SubscribeTerminalResponseSchema, SubscribeTerminalResponseSchema,
TerminalOutputSchema, TerminalOutputSchema,
KillTerminalResponseSchema, KillTerminalResponseSchema,
AttachTerminalStreamResponseSchema,
DetachTerminalStreamResponseSchema,
TerminalStreamExitSchema,
]); ]);
export type SessionOutboundMessage = z.infer< export type SessionOutboundMessage = z.infer<
@@ -2028,6 +2079,11 @@ export type TerminalInput = z.infer<typeof TerminalInputSchema>;
export type TerminalOutput = z.infer<typeof TerminalOutputSchema>; export type TerminalOutput = z.infer<typeof TerminalOutputSchema>;
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>; export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>; export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
export type AttachTerminalStreamRequest = z.infer<typeof AttachTerminalStreamRequestSchema>;
export type AttachTerminalStreamResponse = z.infer<typeof AttachTerminalStreamResponseSchema>;
export type DetachTerminalStreamRequest = z.infer<typeof DetachTerminalStreamRequestSchema>;
export type DetachTerminalStreamResponse = z.infer<typeof DetachTerminalStreamResponseSchema>;
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
// ============================================================================ // ============================================================================
// WebSocket Level Messages (wraps session messages) // WebSocket Level Messages (wraps session messages)
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { encodeTerminalKeyInput } from "./terminal-key-input.js";
describe("encodeTerminalKeyInput", () => {
it("encodes ctrl+b for tmux prefix", () => {
expect(encodeTerminalKeyInput({ key: "b", ctrl: true })).toBe("\x02");
});
it("encodes shifted arrow key modifiers", () => {
expect(encodeTerminalKeyInput({ key: "ArrowLeft", shift: true })).toBe("\x1b[1;2D");
});
it("encodes alt-modified printable keys", () => {
expect(encodeTerminalKeyInput({ key: "x", alt: true })).toBe("\x1bx");
});
it("encodes enter and backspace", () => {
expect(encodeTerminalKeyInput({ key: "Enter" })).toBe("\r");
expect(encodeTerminalKeyInput({ key: "Backspace" })).toBe("\x7f");
});
it("returns empty string for unsupported keys", () => {
expect(encodeTerminalKeyInput({ key: "UnidentifiedKey" })).toBe("");
});
});
@@ -0,0 +1,146 @@
export interface TerminalKeyInput {
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
}
function modifierParam(input: TerminalKeyInput): number {
let value = 1;
if (input.shift) value += 1;
if (input.alt || input.meta) value += 2;
if (input.ctrl) value += 4;
return value;
}
function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string {
return input.alt || input.meta ? `\x1b${sequence}` : sequence;
}
function encodePrintableKey(input: TerminalKeyInput): string {
const raw = input.key;
const char = input.shift ? raw.toUpperCase() : raw;
if (input.ctrl) {
const upper = char.toUpperCase();
if (upper.length === 1 && upper >= "A" && upper <= "Z") {
return applyAltLikePrefix(String.fromCharCode(upper.charCodeAt(0) - 64), input);
}
switch (char) {
case " ":
case "@":
case "2":
return applyAltLikePrefix("\x00", input);
case "[":
case "3":
return applyAltLikePrefix("\x1b", input);
case "\\":
case "4":
return applyAltLikePrefix("\x1c", input);
case "]":
case "5":
return applyAltLikePrefix("\x1d", input);
case "^":
case "6":
return applyAltLikePrefix("\x1e", input);
case "_":
case "/":
case "7":
return applyAltLikePrefix("\x1f", input);
case "8":
case "?":
return applyAltLikePrefix("\x7f", input);
default:
break;
}
if (char.length === 1) {
const code = char.charCodeAt(0) & 0x1f;
return applyAltLikePrefix(String.fromCharCode(code), input);
}
}
return applyAltLikePrefix(char, input);
}
function csiWithModifier(finalByte: string, input: TerminalKeyInput): string {
const mod = modifierParam(input);
return mod === 1 ? `\x1b[${finalByte}` : `\x1b[1;${mod}${finalByte}`;
}
function csiTilde(base: number, input: TerminalKeyInput): string {
const mod = modifierParam(input);
return mod === 1 ? `\x1b[${base}~` : `\x1b[${base};${mod}~`;
}
export function encodeTerminalKeyInput(input: TerminalKeyInput): string {
const key = input.key;
if (!key) {
return "";
}
if (key.length === 1) {
return encodePrintableKey(input);
}
switch (key) {
case "Enter":
return applyAltLikePrefix("\r", input);
case "Tab":
if (input.shift && !input.ctrl && !input.alt && !input.meta) {
return "\x1b[Z";
}
return applyAltLikePrefix("\t", input);
case "Backspace":
return applyAltLikePrefix("\x7f", input);
case "Escape":
return "\x1b";
case "ArrowUp":
return csiWithModifier("A", input);
case "ArrowDown":
return csiWithModifier("B", input);
case "ArrowRight":
return csiWithModifier("C", input);
case "ArrowLeft":
return csiWithModifier("D", input);
case "Home":
return csiWithModifier("H", input);
case "End":
return csiWithModifier("F", input);
case "Insert":
return csiTilde(2, input);
case "Delete":
return csiTilde(3, input);
case "PageUp":
return csiTilde(5, input);
case "PageDown":
return csiTilde(6, input);
case "F1":
return modifierParam(input) === 1 ? "\x1bOP" : csiWithModifier("P", input);
case "F2":
return modifierParam(input) === 1 ? "\x1bOQ" : csiWithModifier("Q", input);
case "F3":
return modifierParam(input) === 1 ? "\x1bOR" : csiWithModifier("R", input);
case "F4":
return modifierParam(input) === 1 ? "\x1bOS" : csiWithModifier("S", input);
case "F5":
return csiTilde(15, input);
case "F6":
return csiTilde(17, input);
case "F7":
return csiTilde(18, input);
case "F8":
return csiTilde(19, input);
case "F9":
return csiTilde(20, input);
case "F10":
return csiTilde(21, input);
case "F11":
return csiTilde(23, input);
case "F12":
return csiTilde(24, input);
default:
return "";
}
}
@@ -429,6 +429,91 @@ describe("Terminal", () => {
}); });
}); });
describe("raw output stream", () => {
it("streams raw output chunks with byte offsets", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
await waitForLines(session, ["$"]);
const chunks: Array<{ data: string; startOffset: number; endOffset: number }> = [];
const sub = session.subscribeRaw((chunk) => {
chunks.push({
data: chunk.data,
startOffset: chunk.startOffset,
endOffset: chunk.endOffset,
});
});
session.send({ type: "input", data: "echo raw-stream\r" });
await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]);
expect(chunks.length).toBeGreaterThan(0);
expect(chunks[chunks.length - 1].endOffset).toBe(session.getOutputOffset());
for (let i = 1; i < chunks.length; i++) {
expect(chunks[i].startOffset).toBe(chunks[i - 1].endOffset);
}
sub.unsubscribe();
});
it("replays buffered output from offset", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
await waitForLines(session, ["$"]);
const seen: Array<{ data: string; endOffset: number; replay: boolean }> = [];
const firstSub = session.subscribeRaw((chunk) => {
seen.push({
data: chunk.data,
endOffset: chunk.endOffset,
replay: chunk.replay,
});
});
session.send({ type: "input", data: "echo before-detach\r" });
await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]);
const resumeOffset = seen[seen.length - 1]?.endOffset ?? 0;
firstSub.unsubscribe();
session.send({ type: "input", data: "echo after-detach\r" });
await waitForLines(session, [
"$ echo before-detach",
"before-detach",
"$ echo after-detach",
"after-detach",
"$",
]);
const replayed: string[] = [];
const secondSub = session.subscribeRaw(
(chunk) => {
if (chunk.replay) {
replayed.push(chunk.data);
}
},
{ fromOffset: resumeOffset }
);
const replayText = replayed.join("");
expect(replayText).toContain("after-detach");
expect(secondSub.replayedFrom).toBeGreaterThanOrEqual(resumeOffset);
secondSub.unsubscribe();
});
});
describe("getState", () => { describe("getState", () => {
it("returns current terminal state with grid", async () => { it("returns current terminal state with grid", async () => {
const session = trackSession( const session = trackSession(
+132 -4
View File
@@ -34,6 +34,21 @@ export interface CellChange {
cell: Cell; cell: Cell;
} }
export interface TerminalRawChunk {
data: string;
startOffset: number;
endOffset: number;
replay: boolean;
}
export interface TerminalRawSubscriptionResult {
unsubscribe: () => void;
replayedFrom: number;
currentOffset: number;
earliestAvailableOffset: number;
reset: boolean;
}
export type ClientMessage = export type ClientMessage =
| { type: "input"; data: string } | { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number } | { type: "resize"; rows: number; cols: number }
@@ -49,6 +64,11 @@ export interface TerminalSession {
cwd: string; cwd: string;
send(msg: ClientMessage): void; send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void; subscribe(listener: (msg: ServerMessage) => void): () => void;
subscribeRaw(
listener: (chunk: TerminalRawChunk) => void,
options?: { fromOffset?: number }
): TerminalRawSubscriptionResult;
getOutputOffset(): number;
getState(): TerminalState; getState(): TerminalState;
kill(): void; kill(): void;
} }
@@ -169,6 +189,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
const id = randomUUID(); const id = randomUUID();
const listeners = new Set<(msg: ServerMessage) => void>(); const listeners = new Set<(msg: ServerMessage) => void>();
const rawListeners = new Set<(chunk: TerminalRawChunk) => void>();
const rawOutputChunks: Array<{
startOffset: number;
endOffset: number;
data: string;
}> = [];
const maxRawBufferBytes = 8 * 1024 * 1024;
let rawBufferBytes = 0;
let outputOffset = 0;
let killed = false; let killed = false;
// Create xterm.js headless terminal // Create xterm.js headless terminal
@@ -192,9 +221,66 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}, },
}); });
function appendRawOutputChunk(data: string): void {
const chunkBytes = Buffer.byteLength(data);
if (chunkBytes <= 0) {
return;
}
const startOffset = outputOffset;
const endOffset = startOffset + chunkBytes;
outputOffset = endOffset;
rawOutputChunks.push({
startOffset,
endOffset,
data,
});
rawBufferBytes += chunkBytes;
while (rawBufferBytes > maxRawBufferBytes && rawOutputChunks.length > 0) {
const removed = rawOutputChunks.shift();
if (!removed) {
break;
}
rawBufferBytes -= removed.endOffset - removed.startOffset;
}
const chunk: TerminalRawChunk = {
data,
startOffset,
endOffset,
replay: false,
};
for (const listener of rawListeners) {
listener(chunk);
}
}
function getEarliestAvailableOffset(): number {
const earliest = rawOutputChunks[0];
return earliest ? earliest.startOffset : outputOffset;
}
function alignOffsetToChunkBoundary(offset: number): number {
if (offset === outputOffset) {
return offset;
}
for (const chunk of rawOutputChunks) {
if (offset === chunk.startOffset || offset === chunk.endOffset) {
return offset;
}
if (offset > chunk.startOffset && offset < chunk.endOffset) {
return chunk.startOffset;
}
}
return offset;
}
// Pipe PTY output to terminal emulator // Pipe PTY output to terminal emulator
ptyProcess.onData((data) => { ptyProcess.onData((data) => {
if (killed) return; if (killed) return;
appendRawOutputChunk(data);
terminal.write(data, () => { terminal.write(data, () => {
// Notify listeners of changes // Notify listeners of changes
const state = getState(); const state = getState();
@@ -254,11 +340,51 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}; };
} }
function subscribeRaw(
listener: (chunk: TerminalRawChunk) => void,
options?: { fromOffset?: number }
): TerminalRawSubscriptionResult {
const requestedOffset = Math.max(0, Math.floor(options?.fromOffset ?? 0));
const earliestAvailableOffset = getEarliestAvailableOffset();
const clampedOffset = Math.max(requestedOffset, earliestAvailableOffset);
const replayedFrom = alignOffsetToChunkBoundary(clampedOffset);
const reset = replayedFrom !== requestedOffset;
for (const chunk of rawOutputChunks) {
if (chunk.endOffset <= replayedFrom) {
continue;
}
listener({
data: chunk.data,
startOffset: chunk.startOffset,
endOffset: chunk.endOffset,
replay: true,
});
}
rawListeners.add(listener);
return {
unsubscribe: () => {
rawListeners.delete(listener);
},
replayedFrom,
currentOffset: outputOffset,
earliestAvailableOffset,
reset,
};
}
function getOutputOffset(): number {
return outputOffset;
}
function kill(): void { function kill(): void {
if (killed) return; if (killed) return;
killed = true; killed = true;
ptyProcess.kill(); ptyProcess.kill();
terminal.dispose(); terminal.dispose();
rawListeners.clear();
} }
// Small delay to let shell initialize // Small delay to let shell initialize
@@ -268,9 +394,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
id, id,
name, name,
cwd, cwd,
send, send,
subscribe, subscribe,
getState, subscribeRaw,
kill, getOutputOffset,
getState,
kill,
}; };
} }