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
@@ -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(),
});
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", [
VoiceAudioChunkMessageSchema,
AbortRequestMessageSchema,
@@ -1041,6 +1056,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UnsubscribeTerminalRequestSchema,
TerminalInputSchema,
KillTerminalRequestSchema,
AttachTerminalStreamRequestSchema,
DetachTerminalStreamRequestSchema,
]);
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", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
@@ -1879,6 +1927,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SubscribeTerminalResponseSchema,
TerminalOutputSchema,
KillTerminalResponseSchema,
AttachTerminalStreamResponseSchema,
DetachTerminalStreamResponseSchema,
TerminalStreamExitSchema,
]);
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 KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
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)
@@ -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 "";
}
}