ompclaw 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ import type { MessageAttachment, OutboundContent, UiRequest, UiResponse } from "../../gateway-types";
2
+ import { isRecord } from "../../type-guards";
3
+
4
+ export const WEBSOCKET_PROTOCOL_VERSION = 1;
5
+ export const WEBSOCKET_TRANSPORT_ID = "websocket";
6
+
7
+ const MAX_CLIENT_MESSAGE_ID_LENGTH = 256;
8
+ const MAX_REQUEST_ID_LENGTH = 256;
9
+ const MAX_ATTACHMENT_COUNT = 32;
10
+ const MAX_ATTACHMENT_URL_LENGTH = 8_192;
11
+ const MAX_ATTACHMENT_NAME_LENGTH = 1_024;
12
+ const MAX_ATTACHMENT_MEDIA_TYPE_LENGTH = 256;
13
+ const MAX_TOKEN_LENGTH = 4_096;
14
+ const MAX_SELECT_VALUES = 256;
15
+
16
+ export interface AuthenticateFrame {
17
+ readonly type: "authenticate";
18
+ readonly token: string;
19
+ }
20
+
21
+ export interface ClientMessageFrame {
22
+ readonly type: "message";
23
+ readonly id: string;
24
+ readonly text?: string;
25
+ readonly attachments?: readonly MessageAttachment[];
26
+ }
27
+
28
+ export interface UiResponseFrame {
29
+ readonly type: "ui_response";
30
+ readonly requestId: string;
31
+ readonly response: UiResponse;
32
+ }
33
+
34
+ export type ClientFrame = AuthenticateFrame | ClientMessageFrame | UiResponseFrame;
35
+
36
+ export interface ReadyFrame {
37
+ readonly type: "ready";
38
+ readonly protocolVersion: typeof WEBSOCKET_PROTOCOL_VERSION;
39
+ }
40
+
41
+ export interface ServerMessageFrame {
42
+ readonly type: "message";
43
+ readonly messageId: string;
44
+ readonly content: OutboundContent;
45
+ }
46
+
47
+ export interface ServerUpdateFrame {
48
+ readonly type: "update";
49
+ readonly messageId: string;
50
+ readonly content: OutboundContent;
51
+ }
52
+
53
+ export interface ServerReactionFrame {
54
+ readonly type: "reaction";
55
+ readonly messageId: string;
56
+ readonly emoji: string;
57
+ }
58
+
59
+ export interface ServerUiRequestFrame {
60
+ readonly type: "ui_request";
61
+ readonly requestId: string;
62
+ readonly request: UiRequest;
63
+ }
64
+
65
+ export interface ServerErrorFrame {
66
+ readonly type: "error";
67
+ readonly code: string;
68
+ readonly message: string;
69
+ }
70
+
71
+ export type ServerFrame =
72
+ | ReadyFrame
73
+ | ServerMessageFrame
74
+ | ServerUpdateFrame
75
+ | ServerReactionFrame
76
+ | ServerUiRequestFrame
77
+ | ServerErrorFrame;
78
+
79
+ export class InvalidWebSocketFrameError extends Error {
80
+ readonly name = "InvalidWebSocketFrameError";
81
+
82
+ constructor(readonly code: "invalid_frame" | "payload_too_large", message: string) {
83
+ super(message);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Parses an exact v1 client frame. The byte limit applies before JSON parsing so
89
+ * malformed peers cannot make the server allocate an unbounded object graph.
90
+ */
91
+ export function parseClientFrame(raw: string | Buffer, maxMessageLength: number): ClientFrame {
92
+ const byteLength = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
93
+ if (byteLength > maxFrameBytes(maxMessageLength)) {
94
+ throw new InvalidWebSocketFrameError("payload_too_large", "frame exceeds the configured payload limit");
95
+ }
96
+
97
+ if (typeof raw !== "string") {
98
+ throw new InvalidWebSocketFrameError("invalid_frame", "binary frames are not supported");
99
+ }
100
+
101
+ let value: unknown;
102
+ try {
103
+ value = JSON.parse(raw);
104
+ } catch {
105
+ throw new InvalidWebSocketFrameError("invalid_frame", "frame is not valid JSON");
106
+ }
107
+
108
+ if (!isRecord(value) || typeof value.type !== "string") {
109
+ throw new InvalidWebSocketFrameError("invalid_frame", "frame must be an object with a type");
110
+ }
111
+
112
+ switch (value.type) {
113
+ case "authenticate":
114
+ return { type: "authenticate", token: boundedString(value.token, "token", MAX_TOKEN_LENGTH, false) };
115
+ case "message":
116
+ return parseMessageFrame(value, maxMessageLength);
117
+ case "ui_response":
118
+ return parseUiResponseFrame(value, maxMessageLength);
119
+ default:
120
+ throw new InvalidWebSocketFrameError("invalid_frame", "unknown frame type");
121
+ }
122
+ }
123
+
124
+ export function maxFrameBytes(maxMessageLength: number): number {
125
+ validateMaxMessageLength(maxMessageLength);
126
+ // Message text is bounded by maxMessageLength. The rest accommodates the
127
+ // protocol envelope and a bounded attachment/UI-response collection.
128
+ return Math.max(1_024, maxMessageLength * 8 + 16_384);
129
+ }
130
+
131
+ export function validateMaxMessageLength(maxMessageLength: number): void {
132
+ if (!Number.isSafeInteger(maxMessageLength) || maxMessageLength < 1 || maxMessageLength > 1_000_000) {
133
+ throw new RangeError("maxMessageLength must be a safe integer between 1 and 1,000,000");
134
+ }
135
+ }
136
+
137
+ function parseMessageFrame(value: Record<string, unknown>, maxMessageLength: number): ClientMessageFrame {
138
+ assertExactKeys(value, ["type", "id", "text", "attachments"]);
139
+ const id = boundedString(value.id, "id", Math.min(maxMessageLength, MAX_CLIENT_MESSAGE_ID_LENGTH), false);
140
+ const text = value.text === undefined ? undefined : boundedString(value.text, "text", maxMessageLength, true);
141
+ const attachments = value.attachments === undefined ? undefined : parseAttachments(value.attachments, maxMessageLength);
142
+ if (text === undefined && attachments === undefined) {
143
+ throw new InvalidWebSocketFrameError("invalid_frame", "message requires text or attachments");
144
+ }
145
+ return {
146
+ type: "message",
147
+ id,
148
+ ...(text === undefined ? {} : { text }),
149
+ ...(attachments === undefined ? {} : { attachments }),
150
+ };
151
+ }
152
+
153
+ function parseUiResponseFrame(value: Record<string, unknown>, maxMessageLength: number): UiResponseFrame {
154
+ assertExactKeys(value, ["type", "requestId", "response"]);
155
+ return {
156
+ type: "ui_response",
157
+ requestId: boundedString(value.requestId, "requestId", MAX_REQUEST_ID_LENGTH, false),
158
+ response: parseUiResponse(value.response, maxMessageLength),
159
+ };
160
+ }
161
+
162
+ function parseAttachments(value: unknown, maxMessageLength: number): readonly MessageAttachment[] {
163
+ if (!Array.isArray(value) || value.length > MAX_ATTACHMENT_COUNT) {
164
+ throw new InvalidWebSocketFrameError("invalid_frame", "attachments must be a bounded array");
165
+ }
166
+
167
+ return value.map((attachment) => {
168
+ if (!isRecord(attachment)) throw new InvalidWebSocketFrameError("invalid_frame", "attachment must be an object");
169
+ assertExactKeys(attachment, ["url", "name", "mediaType"]);
170
+ const url = boundedString(attachment.url, "attachment.url", MAX_ATTACHMENT_URL_LENGTH, false);
171
+ const name =
172
+ attachment.name === undefined
173
+ ? undefined
174
+ : boundedString(attachment.name, "attachment.name", Math.min(maxMessageLength, MAX_ATTACHMENT_NAME_LENGTH), true);
175
+ const mediaType =
176
+ attachment.mediaType === undefined
177
+ ? undefined
178
+ : boundedString(attachment.mediaType, "attachment.mediaType", MAX_ATTACHMENT_MEDIA_TYPE_LENGTH, true);
179
+ return { url, ...(name === undefined ? {} : { name }), ...(mediaType === undefined ? {} : { mediaType }) };
180
+ });
181
+ }
182
+
183
+ function parseUiResponse(value: unknown, maxMessageLength: number): UiResponse {
184
+ if (!isRecord(value) || typeof value.type !== "string") {
185
+ throw new InvalidWebSocketFrameError("invalid_frame", "response must be an object with a type");
186
+ }
187
+
188
+ switch (value.type) {
189
+ case "confirm":
190
+ assertExactKeys(value, ["type", "confirmed"]);
191
+ if (typeof value.confirmed !== "boolean") invalid("response.confirmed must be a boolean");
192
+ return { type: "confirm", confirmed: value.confirmed };
193
+ case "select": {
194
+ assertExactKeys(value, ["type", "selected"]);
195
+ if (!Array.isArray(value.selected) || value.selected.length > MAX_SELECT_VALUES) {
196
+ invalid("response.selected must be a bounded array");
197
+ }
198
+ return {
199
+ type: "select",
200
+ selected: value.selected.map((selected) => boundedString(selected, "response.selected", maxMessageLength, true)),
201
+ };
202
+ }
203
+ case "input":
204
+ return parseTextResponse(value, "input", maxMessageLength);
205
+ case "editor":
206
+ return parseTextResponse(value, "editor", maxMessageLength);
207
+ case "notify":
208
+ case "status":
209
+ case "widget":
210
+ case "title":
211
+ case "editor_text":
212
+ assertExactKeys(value, ["type", "acknowledged"]);
213
+ if (value.acknowledged !== true) invalid("response.acknowledged must be true");
214
+ return { type: value.type, acknowledged: true };
215
+ case "open_url":
216
+ assertExactKeys(value, ["type", "opened"]);
217
+ if (typeof value.opened !== "boolean") invalid("response.opened must be a boolean");
218
+ return { type: "open_url", opened: value.opened };
219
+ default:
220
+ invalid("unknown UI response type");
221
+ }
222
+ }
223
+
224
+ function parseTextResponse(
225
+ value: Record<string, unknown>,
226
+ type: "input" | "editor",
227
+ maxMessageLength: number,
228
+ ): UiResponse {
229
+ assertExactKeys(value, ["type", "cancelled", "value"]);
230
+ if (typeof value.cancelled !== "boolean") invalid("response.cancelled must be a boolean");
231
+ if (value.cancelled) {
232
+ if (value.value !== undefined) invalid("cancelled responses must not include a value");
233
+ return { type, cancelled: true };
234
+ }
235
+ return { type, cancelled: false, value: boundedString(value.value, "response.value", maxMessageLength, true) };
236
+ }
237
+
238
+ function boundedString(value: unknown, field: string, maxLength: number, allowEmpty: boolean): string {
239
+ if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
240
+ throw new InvalidWebSocketFrameError("invalid_frame", `${field} must be a ${allowEmpty ? "string" : "non-empty string"}`);
241
+ }
242
+ if (value.length > maxLength) {
243
+ throw new InvalidWebSocketFrameError("payload_too_large", `${field} exceeds the configured payload limit`);
244
+ }
245
+ return value;
246
+ }
247
+
248
+ function assertExactKeys(value: Record<string, unknown>, keys: readonly string[]): void {
249
+ if (Object.keys(value).some((key) => !keys.includes(key))) {
250
+ throw new InvalidWebSocketFrameError("invalid_frame", "frame contains unsupported fields");
251
+ }
252
+ }
253
+
254
+ function invalid(message: string): never {
255
+ throw new InvalidWebSocketFrameError("invalid_frame", message);
256
+ }
@@ -0,0 +1,4 @@
1
+ /** Canonical object boundary guard for untrusted JSON and IPC values. */
2
+ export function isRecord(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "esnext",
4
+ "moduleResolution": "bundler",
5
+ "target": "es2024",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "types": ["node", "bun"]
10
+ },
11
+ "include": ["src"],
12
+ "exclude": ["src/**/*.test.ts"]
13
+ }