@frockbot/computer-core 0.0.0 → 0.1.1
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.
- package/package.json +22 -6
- package/src/core.ts +990 -0
- package/src/host-protocol.test.ts +69 -0
- package/src/host-protocol.ts +369 -0
- package/src/index.test.ts +357 -0
- package/tsconfig.json +13 -0
- package/README.md +0 -3
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
computerHostEffectRequestWireV1,
|
|
4
|
+
computerHostEffectResponseWireV1,
|
|
5
|
+
decodeComputerHostEffectRequestV1,
|
|
6
|
+
decodeComputerHostEffectResponseV1,
|
|
7
|
+
type ComputerHostEffectRequestV1,
|
|
8
|
+
} from "./host-protocol.js";
|
|
9
|
+
|
|
10
|
+
const request: ComputerHostEffectRequestV1 = {
|
|
11
|
+
schemaVersion: 1,
|
|
12
|
+
effectId: "tool:1:1:0",
|
|
13
|
+
identity: { userId: "user-1" },
|
|
14
|
+
tenant: { botId: "bot-1" },
|
|
15
|
+
assignment: { providerId: "shared-computer", generation: 1 },
|
|
16
|
+
operation: {
|
|
17
|
+
type: "exec",
|
|
18
|
+
request: {
|
|
19
|
+
executable: "/bin/sh",
|
|
20
|
+
args: ["-c", "printf hello"],
|
|
21
|
+
stdin: Uint8Array.from([1, 2, 3]),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("Computer host protocol", () => {
|
|
27
|
+
test("round-trips exact provider-neutral effects and binary results", () => {
|
|
28
|
+
expect(
|
|
29
|
+
decodeComputerHostEffectRequestV1(
|
|
30
|
+
computerHostEffectRequestWireV1(request),
|
|
31
|
+
),
|
|
32
|
+
).toEqual(request);
|
|
33
|
+
const response = decodeComputerHostEffectResponseV1(
|
|
34
|
+
computerHostEffectResponseWireV1({
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
effectId: request.effectId,
|
|
37
|
+
status: "completed",
|
|
38
|
+
result: {
|
|
39
|
+
type: "exec",
|
|
40
|
+
result: {
|
|
41
|
+
exitCode: 0,
|
|
42
|
+
stdout: Uint8Array.from([104, 105]),
|
|
43
|
+
stderr: new Uint8Array(),
|
|
44
|
+
outputTruncated: false,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
expect(response).toMatchObject({
|
|
50
|
+
status: "completed",
|
|
51
|
+
result: { type: "exec", result: { stdout: Uint8Array.from([104, 105]) } },
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("rejects hidden and symbol request fields", () => {
|
|
56
|
+
const hidden = { ...computerHostEffectRequestWireV1(request) };
|
|
57
|
+
Object.defineProperty(hidden, "secret", { value: true });
|
|
58
|
+
const symbol = {
|
|
59
|
+
...computerHostEffectRequestWireV1(request),
|
|
60
|
+
[Symbol("secret")]: true,
|
|
61
|
+
};
|
|
62
|
+
expect(() => decodeComputerHostEffectRequestV1(hidden)).toThrow(
|
|
63
|
+
"unknown or missing fields",
|
|
64
|
+
);
|
|
65
|
+
expect(() => decodeComputerHostEffectRequestV1(symbol)).toThrow(
|
|
66
|
+
"unknown or missing fields",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComputerAssignment,
|
|
3
|
+
ComputerBrowserAction,
|
|
4
|
+
ComputerBrowserState,
|
|
5
|
+
ComputerExecRequest,
|
|
6
|
+
ComputerExecResult,
|
|
7
|
+
ComputerIdentityV1,
|
|
8
|
+
ComputerTenantV1,
|
|
9
|
+
} from "./core.js";
|
|
10
|
+
|
|
11
|
+
export type ComputerHostOperationV1 =
|
|
12
|
+
| { type: "exec"; request: ComputerExecRequest }
|
|
13
|
+
| { type: "browser"; action: ComputerBrowserAction };
|
|
14
|
+
|
|
15
|
+
export interface ComputerHostEffectRequestV1 {
|
|
16
|
+
schemaVersion: 1;
|
|
17
|
+
effectId: string;
|
|
18
|
+
identity: ComputerIdentityV1;
|
|
19
|
+
tenant: ComputerTenantV1;
|
|
20
|
+
assignment: ComputerAssignment;
|
|
21
|
+
operation: ComputerHostOperationV1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ComputerHostEffectResultV1 =
|
|
25
|
+
| { type: "exec"; result: ComputerExecResult }
|
|
26
|
+
| { type: "browser"; result: ComputerBrowserState };
|
|
27
|
+
|
|
28
|
+
export type ComputerHostEffectResponseV1 =
|
|
29
|
+
| {
|
|
30
|
+
schemaVersion: 1;
|
|
31
|
+
effectId: string;
|
|
32
|
+
status: "completed";
|
|
33
|
+
result: ComputerHostEffectResultV1;
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
schemaVersion: 1;
|
|
37
|
+
effectId: string;
|
|
38
|
+
status: "unresolved";
|
|
39
|
+
failure: string;
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
schemaVersion: 1;
|
|
43
|
+
effectId: string;
|
|
44
|
+
status: "rejected";
|
|
45
|
+
failure: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function record(
|
|
49
|
+
input: unknown,
|
|
50
|
+
allowed: readonly string[],
|
|
51
|
+
label: string,
|
|
52
|
+
): Record<string, unknown> {
|
|
53
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
54
|
+
throw new Error(`${label} must be an object`);
|
|
55
|
+
}
|
|
56
|
+
const keys = Reflect.ownKeys(input);
|
|
57
|
+
if (
|
|
58
|
+
keys.length !== allowed.length ||
|
|
59
|
+
keys.some(
|
|
60
|
+
(key) =>
|
|
61
|
+
typeof key !== "string" ||
|
|
62
|
+
!allowed.includes(key) ||
|
|
63
|
+
!Object.prototype.propertyIsEnumerable.call(input, key),
|
|
64
|
+
)
|
|
65
|
+
) {
|
|
66
|
+
throw new Error(`${label} has unknown or missing fields`);
|
|
67
|
+
}
|
|
68
|
+
return input as Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function identifier(input: unknown, label: string): string {
|
|
72
|
+
if (
|
|
73
|
+
typeof input !== "string" ||
|
|
74
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(input)
|
|
75
|
+
) {
|
|
76
|
+
throw new Error(`${label} is invalid`);
|
|
77
|
+
}
|
|
78
|
+
return input;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function finiteInteger(input: unknown, label: string): number {
|
|
82
|
+
if (!Number.isSafeInteger(input) || (input as number) < 0) {
|
|
83
|
+
throw new Error(`${label} is invalid`);
|
|
84
|
+
}
|
|
85
|
+
return input as number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function bytes(input: unknown, label: string): Uint8Array {
|
|
89
|
+
if (
|
|
90
|
+
!Array.isArray(input) ||
|
|
91
|
+
input.some(
|
|
92
|
+
(value) =>
|
|
93
|
+
!Number.isSafeInteger(value) || (value as number) < 0 || value > 255,
|
|
94
|
+
)
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(`${label} is invalid`);
|
|
97
|
+
}
|
|
98
|
+
return Uint8Array.from(input as number[]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function browserAction(input: unknown): ComputerBrowserAction {
|
|
102
|
+
if (typeof input !== "object" || input === null) {
|
|
103
|
+
throw new Error("Computer browser action is invalid");
|
|
104
|
+
}
|
|
105
|
+
const type = (input as { type?: unknown }).type;
|
|
106
|
+
const optionalExact = Object.hasOwn(input, "exact") ? ["exact"] : [];
|
|
107
|
+
if (type === "snapshot") {
|
|
108
|
+
record(input, ["type"], "Computer browser action");
|
|
109
|
+
return { type };
|
|
110
|
+
}
|
|
111
|
+
if (type === "navigate") {
|
|
112
|
+
const value = record(input, ["type", "url"], "Computer browser action");
|
|
113
|
+
if (typeof value.url !== "string")
|
|
114
|
+
throw new Error("Computer browser URL is invalid");
|
|
115
|
+
return { type, url: value.url };
|
|
116
|
+
}
|
|
117
|
+
if (type === "click") {
|
|
118
|
+
const value = record(
|
|
119
|
+
input,
|
|
120
|
+
["type", "role", "name", ...optionalExact],
|
|
121
|
+
"Computer browser action",
|
|
122
|
+
);
|
|
123
|
+
if (
|
|
124
|
+
typeof value.role !== "string" ||
|
|
125
|
+
typeof value.name !== "string" ||
|
|
126
|
+
(value.exact !== undefined && typeof value.exact !== "boolean")
|
|
127
|
+
) {
|
|
128
|
+
throw new Error("Computer browser click is invalid");
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
type,
|
|
132
|
+
role: value.role,
|
|
133
|
+
name: value.name,
|
|
134
|
+
...(value.exact === undefined ? {} : { exact: value.exact }),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (type === "fill") {
|
|
138
|
+
const value = record(
|
|
139
|
+
input,
|
|
140
|
+
["type", "label", "text", ...optionalExact],
|
|
141
|
+
"Computer browser action",
|
|
142
|
+
);
|
|
143
|
+
if (
|
|
144
|
+
typeof value.label !== "string" ||
|
|
145
|
+
typeof value.text !== "string" ||
|
|
146
|
+
(value.exact !== undefined && typeof value.exact !== "boolean")
|
|
147
|
+
) {
|
|
148
|
+
throw new Error("Computer browser fill is invalid");
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
type,
|
|
152
|
+
label: value.label,
|
|
153
|
+
text: value.text,
|
|
154
|
+
...(value.exact === undefined ? {} : { exact: value.exact }),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (type === "press") {
|
|
158
|
+
const value = record(input, ["type", "key"], "Computer browser action");
|
|
159
|
+
if (typeof value.key !== "string")
|
|
160
|
+
throw new Error("Computer browser key is invalid");
|
|
161
|
+
return { type, key: value.key };
|
|
162
|
+
}
|
|
163
|
+
if (type === "wait") {
|
|
164
|
+
const value = record(
|
|
165
|
+
input,
|
|
166
|
+
["type", "milliseconds"],
|
|
167
|
+
"Computer browser action",
|
|
168
|
+
);
|
|
169
|
+
return {
|
|
170
|
+
type,
|
|
171
|
+
milliseconds: finiteInteger(value.milliseconds, "Computer browser wait"),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
throw new Error("Computer browser action is invalid");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type ComputerHostEffectWireV1 = Record<string, unknown>;
|
|
178
|
+
|
|
179
|
+
function computerHostWireV1(
|
|
180
|
+
input: ComputerHostEffectRequestV1 | ComputerHostEffectResponseV1,
|
|
181
|
+
): ComputerHostEffectWireV1 {
|
|
182
|
+
const serialized = JSON.stringify(input, (_key, value) =>
|
|
183
|
+
value instanceof Uint8Array ? [...value] : value,
|
|
184
|
+
);
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(serialized) as ComputerHostEffectWireV1;
|
|
187
|
+
} catch {
|
|
188
|
+
throw new Error("Computer host value could not be serialized");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function computerHostEffectRequestWireV1(
|
|
193
|
+
input: ComputerHostEffectRequestV1,
|
|
194
|
+
): ComputerHostEffectWireV1 {
|
|
195
|
+
return computerHostWireV1(input);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function computerHostEffectResponseWireV1(
|
|
199
|
+
input: ComputerHostEffectResponseV1,
|
|
200
|
+
): ComputerHostEffectWireV1 {
|
|
201
|
+
return computerHostWireV1(input);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function decodeComputerHostEffectRequestV1(
|
|
205
|
+
input: unknown,
|
|
206
|
+
): ComputerHostEffectRequestV1 {
|
|
207
|
+
const value = record(
|
|
208
|
+
input,
|
|
209
|
+
[
|
|
210
|
+
"schemaVersion",
|
|
211
|
+
"effectId",
|
|
212
|
+
"identity",
|
|
213
|
+
"tenant",
|
|
214
|
+
"assignment",
|
|
215
|
+
"operation",
|
|
216
|
+
],
|
|
217
|
+
"Computer host request",
|
|
218
|
+
);
|
|
219
|
+
if (value.schemaVersion !== 1)
|
|
220
|
+
throw new Error("Computer host version is invalid");
|
|
221
|
+
const identity = record(value.identity, ["userId"], "Computer identity");
|
|
222
|
+
const tenant = record(value.tenant, ["botId"], "Computer tenant");
|
|
223
|
+
const assignment = record(
|
|
224
|
+
value.assignment,
|
|
225
|
+
["providerId", "generation"],
|
|
226
|
+
"Computer assignment",
|
|
227
|
+
);
|
|
228
|
+
const operationValue = value.operation;
|
|
229
|
+
if (typeof operationValue !== "object" || operationValue === null) {
|
|
230
|
+
throw new Error("Computer operation is invalid");
|
|
231
|
+
}
|
|
232
|
+
const operationType = (operationValue as { type?: unknown }).type;
|
|
233
|
+
let operation: ComputerHostOperationV1;
|
|
234
|
+
if (operationType === "exec") {
|
|
235
|
+
const decoded = record(
|
|
236
|
+
operationValue,
|
|
237
|
+
["type", "request"],
|
|
238
|
+
"Computer exec operation",
|
|
239
|
+
);
|
|
240
|
+
if (typeof decoded.request !== "object" || decoded.request === null) {
|
|
241
|
+
throw new Error("Computer exec request is invalid");
|
|
242
|
+
}
|
|
243
|
+
const request = record(
|
|
244
|
+
decoded.request,
|
|
245
|
+
[
|
|
246
|
+
"executable",
|
|
247
|
+
"args",
|
|
248
|
+
"cwd",
|
|
249
|
+
"env",
|
|
250
|
+
"stdin",
|
|
251
|
+
"timeoutMs",
|
|
252
|
+
"maxOutputBytes",
|
|
253
|
+
].filter((key) => Object.hasOwn(decoded.request as object, key)),
|
|
254
|
+
"Computer exec request",
|
|
255
|
+
);
|
|
256
|
+
if (typeof request.executable !== "string" || !request.executable) {
|
|
257
|
+
throw new Error("Computer executable is invalid");
|
|
258
|
+
}
|
|
259
|
+
operation = {
|
|
260
|
+
type: "exec",
|
|
261
|
+
request: {
|
|
262
|
+
executable: request.executable,
|
|
263
|
+
...(Array.isArray(request.args) &&
|
|
264
|
+
request.args.every((item) => typeof item === "string")
|
|
265
|
+
? { args: request.args as string[] }
|
|
266
|
+
: request.args === undefined
|
|
267
|
+
? {}
|
|
268
|
+
: (() => {
|
|
269
|
+
throw new Error("Computer args are invalid");
|
|
270
|
+
})()),
|
|
271
|
+
...(typeof request.cwd === "string" ? { cwd: request.cwd } : {}),
|
|
272
|
+
...(request.env === undefined
|
|
273
|
+
? {}
|
|
274
|
+
: {
|
|
275
|
+
env: record(
|
|
276
|
+
request.env,
|
|
277
|
+
Object.keys(request.env as object),
|
|
278
|
+
"Computer env",
|
|
279
|
+
) as Record<string, string>,
|
|
280
|
+
}),
|
|
281
|
+
...(request.stdin === undefined
|
|
282
|
+
? {}
|
|
283
|
+
: { stdin: bytes(request.stdin, "Computer stdin") }),
|
|
284
|
+
...(request.timeoutMs === undefined
|
|
285
|
+
? {}
|
|
286
|
+
: {
|
|
287
|
+
timeoutMs: finiteInteger(request.timeoutMs, "Computer timeout"),
|
|
288
|
+
}),
|
|
289
|
+
...(request.maxOutputBytes === undefined
|
|
290
|
+
? {}
|
|
291
|
+
: {
|
|
292
|
+
maxOutputBytes: finiteInteger(
|
|
293
|
+
request.maxOutputBytes,
|
|
294
|
+
"Computer output limit",
|
|
295
|
+
),
|
|
296
|
+
}),
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
} else if (operationType === "browser") {
|
|
300
|
+
const decoded = record(
|
|
301
|
+
operationValue,
|
|
302
|
+
["type", "action"],
|
|
303
|
+
"Computer browser operation",
|
|
304
|
+
);
|
|
305
|
+
operation = { type: "browser", action: browserAction(decoded.action) };
|
|
306
|
+
} else {
|
|
307
|
+
throw new Error("Computer operation is invalid");
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
schemaVersion: 1,
|
|
311
|
+
effectId: identifier(value.effectId, "Computer effect ID"),
|
|
312
|
+
identity: {
|
|
313
|
+
userId: identifier(identity.userId, "Computer user ID"),
|
|
314
|
+
},
|
|
315
|
+
tenant: {
|
|
316
|
+
botId: identifier(tenant.botId, "Computer Bot ID"),
|
|
317
|
+
},
|
|
318
|
+
assignment: {
|
|
319
|
+
providerId: identifier(assignment.providerId, "Computer provider ID"),
|
|
320
|
+
generation: finiteInteger(assignment.generation, "Computer generation"),
|
|
321
|
+
},
|
|
322
|
+
operation,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function decodeComputerHostEffectResponseV1(
|
|
327
|
+
input: unknown,
|
|
328
|
+
): ComputerHostEffectResponseV1 {
|
|
329
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
330
|
+
throw new Error("Computer host response must be an object");
|
|
331
|
+
}
|
|
332
|
+
const status = (input as { status?: unknown }).status;
|
|
333
|
+
const allowed =
|
|
334
|
+
status === "completed"
|
|
335
|
+
? ["schemaVersion", "effectId", "status", "result"]
|
|
336
|
+
: ["schemaVersion", "effectId", "status", "failure"];
|
|
337
|
+
const value = record(input, allowed, "Computer host response");
|
|
338
|
+
if (value.schemaVersion !== 1)
|
|
339
|
+
throw new Error("Computer host response version is invalid");
|
|
340
|
+
const effectId = identifier(value.effectId, "Computer effect ID");
|
|
341
|
+
if (status === "unresolved" || status === "rejected") {
|
|
342
|
+
if (typeof value.failure !== "string" || !value.failure) {
|
|
343
|
+
throw new Error("Computer host failure is invalid");
|
|
344
|
+
}
|
|
345
|
+
return { schemaVersion: 1, effectId, status, failure: value.failure };
|
|
346
|
+
}
|
|
347
|
+
if (status !== "completed")
|
|
348
|
+
throw new Error("Computer host status is invalid");
|
|
349
|
+
const result = value.result as ComputerHostEffectResultV1;
|
|
350
|
+
if (result?.type === "exec") {
|
|
351
|
+
return {
|
|
352
|
+
schemaVersion: 1,
|
|
353
|
+
effectId,
|
|
354
|
+
status,
|
|
355
|
+
result: {
|
|
356
|
+
type: "exec",
|
|
357
|
+
result: {
|
|
358
|
+
...result.result,
|
|
359
|
+
stdout: bytes(result.result.stdout, "Computer stdout"),
|
|
360
|
+
stderr: bytes(result.result.stderr, "Computer stderr"),
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (result?.type === "browser") {
|
|
366
|
+
return { schemaVersion: 1, effectId, status, result };
|
|
367
|
+
}
|
|
368
|
+
throw new Error("Computer host result is invalid");
|
|
369
|
+
}
|