Extract client SDK package (#1052)

* Extract client SDK package

* Polish SDK client identity defaults

* Build client before dependent CI jobs

* Restore daemon client server export

* Extract protocol and client SDK packages

* Fix provider override schema validation

* Fix app test daemon client imports

* Simplify workspace build targets

* Fix CLI test server build bootstrap

* Run SDK package tests in CI

* Fix rebase package split drift

* Restore lockfile registry metadata

* Update SDK config test for prompt default

* Move terminal stream router test to client package

* Fix rebase drift for protocol imports

* Fix SDK agent capability fixture

* Restore legacy server client exports

* Fix server export compatibility test

* Advertise custom mode icon client capability

* Remove server daemon-client exports

* Format rebased mode control import

* Fix rebase drift for protocol imports

Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split
shared/ paths that this branch moves into @getpaseo/protocol. Redirect
those imports to the protocol package so typecheck stays green after the
rebase.
This commit is contained in:
Mohamed Boudra
2026-05-28 01:58:18 +08:00
committed by GitHub
parent 0ea41378a4
commit 53c14d9855
403 changed files with 3193 additions and 1417 deletions
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import {
FileTransferOpcode,
TerminalStreamOpcode,
decodeFileTransferFrame,
decodeTerminalStreamFrame,
encodeFileTransferFrame,
} from "./index.js";
describe("file transfer binary frames", () => {
const encoder = new TextEncoder();
it("uses non-terminal opcodes", () => {
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileBegin);
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileChunk);
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileEnd);
});
it("encodes FileBegin as opcode plus request id prefix plus JSON metadata", () => {
const encoded = encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-1",
metadata: {
mime: "image/png",
size: 6,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
});
const requestId = encoder.encode("req-1");
const metadata = encoder.encode(
JSON.stringify({
mime: "image/png",
size: 6,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
}),
);
expect(decodeTerminalStreamFrame(encoded)).toBeNull();
expect(encoded[0]).toBe(FileTransferOpcode.FileBegin);
expect(encoded[1]).toBe(requestId.byteLength);
expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId);
expect(
new DataView(encoded.buffer, encoded.byteOffset).getUint16(2 + requestId.byteLength),
).toBe(metadata.byteLength);
expect(encoded.subarray(4 + requestId.byteLength)).toEqual(metadata);
expect(decodeFileTransferFrame(encoded)).toEqual({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-1",
metadata: {
mime: "image/png",
size: 6,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
payload: new Uint8Array(),
});
});
it("encodes FileChunk as opcode plus request id prefix plus binary payload", () => {
const payload = new Uint8Array([0, 1, 2, 253, 254, 255]);
const encoded = encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-1",
payload,
});
const requestId = encoder.encode("req-1");
expect(encoded[0]).toBe(FileTransferOpcode.FileChunk);
expect(encoded[1]).toBe(requestId.byteLength);
expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId);
expect(encoded.subarray(2 + requestId.byteLength)).toEqual(payload);
const decoded = decodeFileTransferFrame(encoded);
expect(decoded).toEqual({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-1",
payload,
});
expect(decoded?.payload).toBeInstanceOf(Uint8Array);
expect(decoded?.payload).toEqual(payload);
});
it("encodes FileEnd as opcode plus request id prefix only", () => {
const encoded = encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId: "req-1",
});
const requestId = encoder.encode("req-1");
expect(encoded).toEqual(
new Uint8Array([FileTransferOpcode.FileEnd, requestId.byteLength, ...requestId]),
);
expect(decodeFileTransferFrame(encoded)).toEqual({
opcode: FileTransferOpcode.FileEnd,
requestId: "req-1",
payload: new Uint8Array(),
});
});
it("rejects malformed metadata and unknown metadata fields", () => {
expect(
decodeFileTransferFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-1",
metadata: {
mime: "image/png",
size: -1,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
}),
),
).toBeNull();
const json = encoder.encode(
JSON.stringify({
mime: "image/png",
size: 1,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
extra: true,
}),
);
const requestId = encoder.encode("req-1");
const encoded = new Uint8Array(4 + requestId.byteLength + json.byteLength);
encoded[0] = FileTransferOpcode.FileBegin;
encoded[1] = requestId.byteLength;
encoded.set(requestId, 2);
new DataView(encoded.buffer).setUint16(2 + requestId.byteLength, json.byteLength);
encoded.set(json, 4 + requestId.byteLength);
expect(decodeFileTransferFrame(encoded)).toBeNull();
});
it("rejects malformed request id prefixes and frame tails", () => {
expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 0]))).toBeNull();
expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 6, 1]))).toBeNull();
const requestId = encoder.encode("req-1");
const encoded = new Uint8Array([
FileTransferOpcode.FileEnd,
requestId.byteLength,
...requestId,
1,
]);
expect(decodeFileTransferFrame(encoded)).toBeNull();
});
});
@@ -0,0 +1,163 @@
import { z } from "zod";
import { asUint8Array } from "./terminal.js";
export const FileTransferOpcode = {
FileBegin: 0x10,
FileChunk: 0x11,
FileEnd: 0x12,
} as const;
export type FileTransferOpcode = (typeof FileTransferOpcode)[keyof typeof FileTransferOpcode];
export const FileBeginMetadataSchema = z
.object({
mime: z.string().min(1),
size: z.number().int().nonnegative(),
encoding: z.enum(["utf-8", "binary"]),
modifiedAt: z.string(),
})
.strict();
export interface FileBegin {
opcode: typeof FileTransferOpcode.FileBegin;
requestId: string;
metadata: z.infer<typeof FileBeginMetadataSchema>;
payload: Uint8Array;
}
export interface FileChunk {
opcode: typeof FileTransferOpcode.FileChunk;
requestId: string;
payload: Uint8Array;
}
export interface FileEnd {
opcode: typeof FileTransferOpcode.FileEnd;
requestId: string;
payload: Uint8Array;
}
export type FileTransferFrame = FileBegin | FileChunk | FileEnd;
type FileTransferFrameInput =
| {
opcode: typeof FileTransferOpcode.FileBegin;
requestId: string;
metadata: z.infer<typeof FileBeginMetadataSchema>;
}
| {
opcode: typeof FileTransferOpcode.FileChunk;
requestId: string;
payload?: Uint8Array | ArrayBuffer | string;
}
| {
opcode: typeof FileTransferOpcode.FileEnd;
requestId: string;
};
export function encodeFileTransferFrame(input: FileTransferFrameInput): Uint8Array {
const requestId = encodeRequestId(input.requestId);
if (input.opcode === FileTransferOpcode.FileBegin) {
const metadata = encodeJsonPayload(input.metadata);
if (metadata.byteLength > 0xffff) {
throw new RangeError("FileBegin metadata is too long");
}
const bytes = new Uint8Array(4 + requestId.byteLength + metadata.byteLength);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
bytes[0] = input.opcode;
bytes[1] = requestId.byteLength;
bytes.set(requestId, 2);
view.setUint16(2 + requestId.byteLength, metadata.byteLength);
bytes.set(metadata, 4 + requestId.byteLength);
return bytes;
}
const payload =
input.opcode === FileTransferOpcode.FileChunk
? (asUint8Array(input.payload ?? new Uint8Array()) ?? new Uint8Array())
: new Uint8Array();
const bytes = new Uint8Array(2 + requestId.byteLength + payload.byteLength);
bytes[0] = input.opcode;
bytes[1] = requestId.byteLength;
bytes.set(requestId, 2);
bytes.set(payload, 2 + requestId.byteLength);
return bytes;
}
export function decodeFileTransferFrame(bytes: Uint8Array): FileTransferFrame | null {
if (bytes.byteLength < 2) {
return null;
}
const opcode = bytes[0];
if (!isFileTransferOpcode(opcode)) {
return null;
}
const requestIdLength = bytes[1];
if (requestIdLength === 0 || requestIdLength > bytes.byteLength - 2) {
return null;
}
const requestId = decodeRequestId(bytes.subarray(2, 2 + requestIdLength));
const body = bytes.subarray(2 + requestIdLength);
if (opcode === FileTransferOpcode.FileBegin) {
if (body.byteLength < 2) {
return null;
}
const view = new DataView(body.buffer, body.byteOffset, body.byteLength);
const metadataLength = view.getUint16(0);
if (metadataLength !== body.byteLength - 2) {
return null;
}
const metadataBytes = body.subarray(2);
const result = FileBeginMetadataSchema.safeParse(decodeJsonPayload(metadataBytes));
return result.success
? { opcode, requestId, metadata: result.data, payload: new Uint8Array() }
: null;
}
if (opcode === FileTransferOpcode.FileChunk) {
return { opcode, requestId, payload: body };
}
if (body.byteLength !== 0) {
return null;
}
return { opcode, requestId, payload: new Uint8Array() };
}
function isFileTransferOpcode(value: number): value is FileTransferOpcode {
return (
value === FileTransferOpcode.FileBegin ||
value === FileTransferOpcode.FileChunk ||
value === FileTransferOpcode.FileEnd
);
}
function encodeJsonPayload(value: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(value));
}
function encodeRequestId(requestId: string): Uint8Array {
const bytes = new TextEncoder().encode(requestId);
if (bytes.byteLength === 0) {
throw new RangeError("File transfer requestId is required");
}
if (bytes.byteLength > 0xff) {
throw new RangeError("File transfer requestId is too long");
}
return bytes;
}
function decodeRequestId(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes);
}
function decodeJsonPayload(bytes: Uint8Array): unknown {
try {
return JSON.parse(new TextDecoder().decode(bytes));
} catch {
return null;
}
}
@@ -0,0 +1,2 @@
export * from "./file-transfer.js";
export * from "./terminal.js";
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
TerminalStreamOpcode,
decodeTerminalResizePayload,
decodeTerminalSnapshotPayload,
decodeTerminalStreamFrame,
encodeTerminalResizePayload,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
} from "./index.js";
describe("terminal binary frames", () => {
it("encodes output frames as opcode plus slot plus payload", () => {
const payload = new TextEncoder().encode("hello");
const encoded = encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 7,
payload,
});
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
expect(encoded[1]).toBe(7);
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
const decoded = decodeTerminalStreamFrame(encoded);
expect(decoded).toEqual({
opcode: TerminalStreamOpcode.Output,
slot: 7,
payload,
});
});
it("round-trips resize payloads", () => {
const payload = encodeTerminalResizePayload({
rows: 24,
cols: 80,
});
expect(decodeTerminalResizePayload(payload)).toEqual({
rows: 24,
cols: 80,
});
});
it("round-trips snapshot payloads", () => {
const state = {
rows: 1,
cols: 2,
grid: [[{ char: "A" }, { char: "B" }]],
scrollback: [],
cursor: { row: 0, col: 2 },
};
const payload = encodeTerminalSnapshotPayload(state);
expect(decodeTerminalSnapshotPayload(payload)).toEqual(state);
});
it("rejects unknown opcodes", () => {
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01, 0x02]))).toBeNull();
});
it("rejects frames without a slot byte", () => {
expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull();
});
it("rejects malformed JSON payloads", () => {
const malformed = new TextEncoder().encode("{");
expect(decodeTerminalResizePayload(malformed)).toBeNull();
expect(decodeTerminalSnapshotPayload(malformed)).toBeNull();
});
it("rejects invalid resize and snapshot shapes", () => {
expect(
decodeTerminalResizePayload(
new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })),
),
).toBeNull();
expect(
decodeTerminalSnapshotPayload(
new TextEncoder().encode(
JSON.stringify({
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
}),
),
),
).toBeNull();
});
it("rejects unknown fields in resize and snapshot payloads", () => {
expect(
decodeTerminalResizePayload(
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
),
).toBeNull();
expect(
decodeTerminalSnapshotPayload(
new TextEncoder().encode(
JSON.stringify({
rows: 1,
cols: 1,
grid: [[{ char: "A", extra: true }]],
scrollback: [],
cursor: { row: 0, col: 1 },
}),
),
),
).toBeNull();
});
});
@@ -0,0 +1,133 @@
import { z } from "zod";
import { TerminalStateSchema } from "../messages.js";
export const TerminalStreamResizeSchema = z
.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
})
.strict();
export const TerminalStreamOpcode = {
Output: 0x01,
Input: 0x02,
Resize: 0x03,
Snapshot: 0x04,
Restore: 0x05,
} as const;
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
export interface TerminalStreamFrame {
opcode: TerminalStreamOpcode;
slot: number;
payload: Uint8Array;
}
export function asUint8Array(data: unknown): Uint8Array | null {
if (typeof data === "string") {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(data);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(data, "utf8"));
}
const out = new Uint8Array(data.length);
for (let index = 0; index < data.length; index += 1) {
out[index] = data.charCodeAt(index) & 0xff;
}
return out;
}
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;
}
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
return (
value === TerminalStreamOpcode.Output ||
value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Snapshot ||
value === TerminalStreamOpcode.Restore
);
}
export function encodeTerminalStreamFrame(input: {
opcode: TerminalStreamOpcode;
slot: number;
payload?: Uint8Array | ArrayBuffer | string;
}): Uint8Array {
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
const bytes = new Uint8Array(2 + payload.byteLength);
bytes[0] = input.opcode;
bytes[1] = input.slot & 0xff;
bytes.set(payload, 2);
return bytes;
}
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
if (bytes.byteLength < 2) {
return null;
}
const opcode = bytes[0];
if (!isTerminalStreamOpcode(opcode)) {
return null;
}
return {
opcode,
slot: bytes[1],
payload: bytes.subarray(2),
};
}
export function encodeTerminalSnapshotPayload(
state: z.infer<typeof TerminalStateSchema>,
): Uint8Array {
return encodeJsonPayload(state);
}
export function decodeTerminalSnapshotPayload(
bytes: Uint8Array,
): z.infer<typeof TerminalStateSchema> | null {
const parsed = decodeJsonPayload(bytes);
const result = TerminalStateSchema.safeParse(parsed);
return result.success ? result.data : null;
}
export function encodeTerminalResizePayload(
input: z.infer<typeof TerminalStreamResizeSchema>,
): Uint8Array {
return encodeJsonPayload(input);
}
export function decodeTerminalResizePayload(
bytes: Uint8Array,
): z.infer<typeof TerminalStreamResizeSchema> | null {
const parsed = decodeJsonPayload(bytes);
const result = TerminalStreamResizeSchema.safeParse(parsed);
return result.success ? result.data : null;
}
function encodeJsonPayload(value: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(value));
}
function decodeJsonPayload(bytes: Uint8Array): unknown {
try {
const text = new TextDecoder().decode(bytes);
return JSON.parse(text);
} catch {
return null;
}
}