Update files
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user