@fungi.computer/computer-contract 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +60 -2
- package/dist/index.d.ts +742 -0
- package/dist/index.js +750 -0
- package/docs/api/README.md +2055 -0
- package/package.json +61 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
import { replayKey, defineIdFamily } from "@fungi.computer/cairn";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* The stable Workspace id of one attached execution backend: `container`,
|
|
5
|
+
* `vm-` followed by a lowercase letter-or-digit name that may contain hyphens,
|
|
6
|
+
* or `local-` followed by 16 lowercase base32 characters. A command request
|
|
7
|
+
* names one to run there; omitted, the Computer routes by its default order.
|
|
8
|
+
*/
|
|
9
|
+
export const attachedBackendIdSchema = z.lazy(() => z
|
|
10
|
+
.string()
|
|
11
|
+
.regex(/^(container|vm-[a-z0-9][a-z0-9-]{0,62}|local-[a-z2-7]{16})$/));
|
|
12
|
+
const utf8 = new TextEncoder();
|
|
13
|
+
/**
|
|
14
|
+
* Most bytes of file content one Computer read or write carries, counted as
|
|
15
|
+
* UTF-8.
|
|
16
|
+
*/
|
|
17
|
+
export const maximumFileBytes = 128 * 1_024;
|
|
18
|
+
/** Most entries one list, glob or grep reply returns in a single page. */
|
|
19
|
+
export const maximumListEntries = 256;
|
|
20
|
+
/** Longest glob or search pattern a request may send. */
|
|
21
|
+
export const maximumPatternCharacters = 512;
|
|
22
|
+
/** Longest command string a request may send. */
|
|
23
|
+
export const maximumCommandCharacters = 16 * 1_024;
|
|
24
|
+
/**
|
|
25
|
+
* Most bytes of stdout or stderr kept from one command. A longer stream is cut
|
|
26
|
+
* and marked.
|
|
27
|
+
*/
|
|
28
|
+
export const maximumStreamBytes = 64 * 1_024;
|
|
29
|
+
/** Room inside the stream cap for the visible truncation marker. */
|
|
30
|
+
export const truncationMarkerReserveBytes = 256;
|
|
31
|
+
/** The bound for ordinary Computer commands; hosts own any admitted job budget. */
|
|
32
|
+
export const maximumExecutionTimeMs = 120_000;
|
|
33
|
+
/**
|
|
34
|
+
* The budget for ordinary Computer commands. Hosts that admit a job principal
|
|
35
|
+
* choose their own.
|
|
36
|
+
*/
|
|
37
|
+
export const genericComputerExecutionBudget = Object.freeze({ maximumTimeMs: maximumExecutionTimeMs });
|
|
38
|
+
/**
|
|
39
|
+
* Accepts any JSON value. Capability descriptions and other transport data are
|
|
40
|
+
* checked against it.
|
|
41
|
+
*/
|
|
42
|
+
export const computerValueSchema = z.json();
|
|
43
|
+
/**
|
|
44
|
+
* Accepts the name of one Computer operation: a letter, then letters, digits or
|
|
45
|
+
* underscores.
|
|
46
|
+
*/
|
|
47
|
+
export const capabilityNameSchema = z.lazy(() => z
|
|
48
|
+
.string()
|
|
49
|
+
.min(1)
|
|
50
|
+
.max(128)
|
|
51
|
+
.regex(/^[A-Za-z][A-Za-z0-9_]*$/));
|
|
52
|
+
/** One valid TCP port number, used only with this Computer's container. */
|
|
53
|
+
export const computerPortSchema = z.lazy(() => z.number().int().min(1).max(65_535));
|
|
54
|
+
/** Most preview ports one Computer admits at once. */
|
|
55
|
+
export const maximumExposedPorts = 64;
|
|
56
|
+
/** computerd's Workspace RPC port, pinned by the Cloudflare container backend. */
|
|
57
|
+
export const computerDaemonPort = 8080;
|
|
58
|
+
/** The Computer-owned terminal server inside the container image. */
|
|
59
|
+
export const computerTerminalPort = 7681;
|
|
60
|
+
/**
|
|
61
|
+
* Ports served by the Computer's own in-container daemons. An admitted
|
|
62
|
+
* preview must never reach a control plane.
|
|
63
|
+
*/
|
|
64
|
+
export const computerReservedPorts = new Set([
|
|
65
|
+
computerDaemonPort,
|
|
66
|
+
computerTerminalPort,
|
|
67
|
+
]);
|
|
68
|
+
/**
|
|
69
|
+
* Whether a Computer daemon owns this port, so it can never be admitted for
|
|
70
|
+
* preview.
|
|
71
|
+
*/
|
|
72
|
+
export function isComputerReservedPort(port) {
|
|
73
|
+
return computerReservedPorts.has(port);
|
|
74
|
+
}
|
|
75
|
+
/** Names one container port to expose or remove. */
|
|
76
|
+
export const computerPortInputSchema = z.strictObject({
|
|
77
|
+
/** The container port to act on. */
|
|
78
|
+
port: computerPortSchema,
|
|
79
|
+
});
|
|
80
|
+
/** Reply to a request to expose a port. */
|
|
81
|
+
export const computerPortOutputSchema = z.lazy(() => z.strictObject({
|
|
82
|
+
/** The container port that was requested. */
|
|
83
|
+
port: computerPortSchema,
|
|
84
|
+
/** Whether the port is now admitted for preview. */
|
|
85
|
+
exposed: z.boolean(),
|
|
86
|
+
}));
|
|
87
|
+
/** Reply to a request to stop exposing a port. */
|
|
88
|
+
export const computerPortRemovalOutputSchema = z.lazy(() => z.strictObject({
|
|
89
|
+
/** The container port that was requested. */
|
|
90
|
+
port: computerPortSchema,
|
|
91
|
+
/** Whether the port had been admitted and is now removed. */
|
|
92
|
+
removed: z.boolean(),
|
|
93
|
+
}));
|
|
94
|
+
/** The ports this Computer currently admits for preview. */
|
|
95
|
+
export const computerPortsOutputSchema = z.lazy(() => z.strictObject({
|
|
96
|
+
/** The admitted ports, at most `maximumExposedPorts`. */
|
|
97
|
+
ports: z.array(computerPortSchema).max(maximumExposedPorts),
|
|
98
|
+
}));
|
|
99
|
+
/** The port belongs to a Computer daemon and cannot be admitted for preview. */
|
|
100
|
+
export class ComputerPortReservedError extends Error {
|
|
101
|
+
constructor(port) {
|
|
102
|
+
super(`Computer port ${String(port)} is reserved`);
|
|
103
|
+
this.name = "ComputerPortReservedError";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** A requested port cannot be reserved; no other process identity is disclosed. */
|
|
107
|
+
export class ComputerPortConflictError extends Error {
|
|
108
|
+
constructor() {
|
|
109
|
+
super("Computer port is unavailable");
|
|
110
|
+
this.name = "ComputerPortConflictError";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** This Computer already admits the maximum number of preview ports. */
|
|
114
|
+
export class ComputerPortLimitError extends Error {
|
|
115
|
+
constructor() {
|
|
116
|
+
super(`Computer can expose at most ${String(maximumExposedPorts)} ports`);
|
|
117
|
+
this.name = "ComputerPortLimitError";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Schema keywords are opaque transport data. Zod still validates every value
|
|
121
|
+
// as JSON before the description leaves or enters the Computer boundary.
|
|
122
|
+
const schemaDocument = z.lazy(() => z
|
|
123
|
+
.record(z.string(), z.unknown())
|
|
124
|
+
.refine((value) => computerValueSchema.safeParse(value).success, "Capability schema must contain JSON values"));
|
|
125
|
+
/** Native operation descriptions shared by discovery and its OpenAPI document. */
|
|
126
|
+
export const computerCapabilitiesSchema = z.lazy(() => z.array(z.strictObject({
|
|
127
|
+
/** Identifier of the operation. */
|
|
128
|
+
id: capabilityNameSchema,
|
|
129
|
+
/** Name of the operation. It equals the identifier. */
|
|
130
|
+
name: capabilityNameSchema,
|
|
131
|
+
/** Family the operation belongs to, such as file storage or runtime. */
|
|
132
|
+
kind: z.string(),
|
|
133
|
+
/** What the operation does, in words a model or reader can act on. */
|
|
134
|
+
description: z.string(),
|
|
135
|
+
/** JSON Schema describing the operation's input. */
|
|
136
|
+
parameters: schemaDocument,
|
|
137
|
+
/** JSON Schema describing the operation's result. */
|
|
138
|
+
output: schemaDocument,
|
|
139
|
+
})));
|
|
140
|
+
/**
|
|
141
|
+
* Accepts an absolute path inside the Computer: it starts with a slash and
|
|
142
|
+
* holds no null byte.
|
|
143
|
+
*/
|
|
144
|
+
export const pathSchema = z
|
|
145
|
+
.string()
|
|
146
|
+
.min(1)
|
|
147
|
+
.max(4_096)
|
|
148
|
+
.startsWith("/")
|
|
149
|
+
.refine((path) => !path.includes("\0"), "Path cannot contain null bytes");
|
|
150
|
+
/**
|
|
151
|
+
* Builds a schema for text of at least the given length whose UTF-8 size stays
|
|
152
|
+
* within `maximumFileBytes`.
|
|
153
|
+
*/
|
|
154
|
+
export const boundedTextSchema = (minimumLength = 0) => z
|
|
155
|
+
.string()
|
|
156
|
+
.min(minimumLength)
|
|
157
|
+
.refine((content) => utf8.encode(content).byteLength <= maximumFileBytes, `Text exceeds the ${String(maximumFileBytes)}-byte Computer bound`);
|
|
158
|
+
/** Complete bounded UTF-8 text reads. Binary reads use the same path input. */
|
|
159
|
+
export const readInputSchema = z.strictObject({
|
|
160
|
+
/** The file to read. */
|
|
161
|
+
path: pathSchema,
|
|
162
|
+
});
|
|
163
|
+
/** The whole contents of one file, as text. */
|
|
164
|
+
export const readOutputSchema = z.lazy(() => z.strictObject({
|
|
165
|
+
/** The file that was read. */
|
|
166
|
+
path: pathSchema,
|
|
167
|
+
/** The file's text. */
|
|
168
|
+
content: boundedTextSchema(),
|
|
169
|
+
}));
|
|
170
|
+
/** Optional byte window; text reads remain complete and independently bounded. */
|
|
171
|
+
export const readBytesInputSchema = z.strictObject({
|
|
172
|
+
/** The file to read. */
|
|
173
|
+
path: pathSchema,
|
|
174
|
+
/** First byte of the window. Omitted, the window starts at the beginning. */
|
|
175
|
+
byteOffset: z.number().int().nonnegative().optional(),
|
|
176
|
+
/**
|
|
177
|
+
* Size of the window in bytes, at most `maximumFileBytes`. Omitted, the read
|
|
178
|
+
* runs to that bound.
|
|
179
|
+
*/
|
|
180
|
+
byteLength: z.number().int().positive().max(maximumFileBytes).optional(),
|
|
181
|
+
});
|
|
182
|
+
/** Finite byte-read reply used by admitted App frame bindings. */
|
|
183
|
+
export const computerByteReadReplySchema = z.lazy(() => z.strictObject({
|
|
184
|
+
/** The file that was read. */
|
|
185
|
+
path: readBytesInputSchema.shape.path,
|
|
186
|
+
/** Byte at which the returned window starts. */
|
|
187
|
+
byteOffset: z.number().int().nonnegative(),
|
|
188
|
+
/** The window's bytes, base64 encoded. */
|
|
189
|
+
content: z.string(),
|
|
190
|
+
/** How `content` is encoded. Always base64. */
|
|
191
|
+
encoding: z.literal("base64"),
|
|
192
|
+
/** Whether the file continues past the returned window. */
|
|
193
|
+
truncated: z.boolean(),
|
|
194
|
+
}));
|
|
195
|
+
/** Host-admitted App method preview; parsing does not grant port access. */
|
|
196
|
+
export const appMethodPreviewResultSchema = z.lazy(() => z.strictObject({
|
|
197
|
+
/** Address where the exposed port's preview can be opened. */
|
|
198
|
+
previewUrl: z.url(),
|
|
199
|
+
/** When the preview address stops being valid. */
|
|
200
|
+
expiresAt: z.number().int().positive(),
|
|
201
|
+
}));
|
|
202
|
+
/**
|
|
203
|
+
* Accepts how many entries a list returns: 1 up to `maximumListEntries`, 100
|
|
204
|
+
* when omitted.
|
|
205
|
+
*/
|
|
206
|
+
export const listLimitSchema = z
|
|
207
|
+
.number()
|
|
208
|
+
.int()
|
|
209
|
+
.min(1)
|
|
210
|
+
.max(maximumListEntries)
|
|
211
|
+
.default(100);
|
|
212
|
+
/** Accepts how many entries a list skips. Zero when omitted. */
|
|
213
|
+
export const listOffsetSchema = z.number().int().nonnegative().default(0);
|
|
214
|
+
/** A request for one page of a directory's entries. */
|
|
215
|
+
export const listInputSchema = z.strictObject({
|
|
216
|
+
/** The directory to list. The root when omitted. */
|
|
217
|
+
path: pathSchema.default("/"),
|
|
218
|
+
/** Most entries to return in this page. */
|
|
219
|
+
limit: listLimitSchema,
|
|
220
|
+
/** Entries to skip before this page. */
|
|
221
|
+
offset: listOffsetSchema,
|
|
222
|
+
});
|
|
223
|
+
const listEntrySchema = z.lazy(() => z.strictObject({
|
|
224
|
+
/** The entry's name inside its directory. */
|
|
225
|
+
name: z.string(),
|
|
226
|
+
/** The entry's size in bytes. */
|
|
227
|
+
size: z.number().int().nonnegative(),
|
|
228
|
+
/** When the entry last changed, as the Computer's file store reports it. */
|
|
229
|
+
modifiedAt: z.number(),
|
|
230
|
+
/** Whether the entry is a file, a directory or a symbolic link. */
|
|
231
|
+
type: z.enum({
|
|
232
|
+
/** A regular file. */
|
|
233
|
+
file: "file",
|
|
234
|
+
/** A directory. */
|
|
235
|
+
directory: "directory",
|
|
236
|
+
/** A symbolic link. */
|
|
237
|
+
symlink: "symlink",
|
|
238
|
+
}),
|
|
239
|
+
}));
|
|
240
|
+
/** One page of a directory's entries. */
|
|
241
|
+
export const listOutputSchema = z.lazy(() => z.strictObject({
|
|
242
|
+
/** The directory that was listed. */
|
|
243
|
+
path: pathSchema,
|
|
244
|
+
/** The entries in this page. */
|
|
245
|
+
entries: z.array(listEntrySchema).max(maximumListEntries),
|
|
246
|
+
/** Offset to request for the next page, or null when this was the last. */
|
|
247
|
+
nextOffset: z.number().int().nonnegative().nullable(),
|
|
248
|
+
}));
|
|
249
|
+
/** Accepts text to store in a file, within `maximumFileBytes`. */
|
|
250
|
+
export const fileContentSchema = z.lazy(() => z
|
|
251
|
+
.string()
|
|
252
|
+
.refine((content) => utf8.encode(content).byteLength <= maximumFileBytes, "File exceeds the Computer write bound"));
|
|
253
|
+
/** A request to write one file, creating or replacing its contents. */
|
|
254
|
+
export const writeInputSchema = z.strictObject({
|
|
255
|
+
/** The file to write. */
|
|
256
|
+
path: pathSchema,
|
|
257
|
+
/** The text to store. */
|
|
258
|
+
content: fileContentSchema,
|
|
259
|
+
});
|
|
260
|
+
/** The reply to a committed file write. */
|
|
261
|
+
export const writeOutputSchema = z.lazy(() => z.strictObject({
|
|
262
|
+
/** The file that was written. */
|
|
263
|
+
path: pathSchema,
|
|
264
|
+
/** Size of the stored content in bytes. */
|
|
265
|
+
bytes: z.number().int().nonnegative(),
|
|
266
|
+
}));
|
|
267
|
+
/** Names the file for a binary write. The bytes travel as the request body. */
|
|
268
|
+
export const writeBytesInputSchema = readInputSchema;
|
|
269
|
+
/**
|
|
270
|
+
* A request to replace a file's contents only if they still equal what the
|
|
271
|
+
* caller last saw.
|
|
272
|
+
*/
|
|
273
|
+
export const replaceInputSchema = z.strictObject({
|
|
274
|
+
/** The file to replace. */
|
|
275
|
+
path: pathSchema,
|
|
276
|
+
/**
|
|
277
|
+
* The contents the caller last saw. A mismatch raises `ComputerFileConflict`.
|
|
278
|
+
*/
|
|
279
|
+
expectedContent: fileContentSchema,
|
|
280
|
+
/** The contents to store. */
|
|
281
|
+
content: fileContentSchema,
|
|
282
|
+
});
|
|
283
|
+
/** The reply to a committed replacement. It has the shape of a write reply. */
|
|
284
|
+
export const replaceOutputSchema = writeOutputSchema;
|
|
285
|
+
/** A request to find paths under a directory that match a glob. */
|
|
286
|
+
export const globInputSchema = z.lazy(() => z.strictObject({
|
|
287
|
+
/** The directory to search under. */
|
|
288
|
+
path: pathSchema,
|
|
289
|
+
/** The glob that paths must match. */
|
|
290
|
+
pattern: z.string().min(1).max(maximumPatternCharacters),
|
|
291
|
+
/** Most matches to return. */
|
|
292
|
+
limit: z
|
|
293
|
+
.number()
|
|
294
|
+
.int()
|
|
295
|
+
.min(1)
|
|
296
|
+
.max(maximumListEntries)
|
|
297
|
+
.default(maximumListEntries),
|
|
298
|
+
}));
|
|
299
|
+
/** The paths a glob found. */
|
|
300
|
+
export const globOutputSchema = z.lazy(() => z.strictObject({
|
|
301
|
+
/** The directory that was searched. */
|
|
302
|
+
path: pathSchema,
|
|
303
|
+
/** The matching paths. */
|
|
304
|
+
matches: z.array(pathSchema).max(maximumListEntries),
|
|
305
|
+
/** Whether more paths matched than were returned. */
|
|
306
|
+
truncated: z.boolean(),
|
|
307
|
+
}));
|
|
308
|
+
/** A request to search file contents under a directory. */
|
|
309
|
+
export const grepInputSchema = z.lazy(() => z.strictObject({
|
|
310
|
+
/** The directory to search under. */
|
|
311
|
+
path: pathSchema,
|
|
312
|
+
/** The text to look for, or a regular expression when `regex` is true. */
|
|
313
|
+
pattern: z.string().min(1).max(maximumPatternCharacters),
|
|
314
|
+
/** Whether `pattern` is a regular expression. False when omitted. */
|
|
315
|
+
regex: z.boolean().default(false),
|
|
316
|
+
/** Whether letter case is ignored. False when omitted. */
|
|
317
|
+
ignoreCase: z.boolean().default(false),
|
|
318
|
+
/** Glob, relative to `path`, that limits which files are searched. */
|
|
319
|
+
include: z.string().min(1).max(maximumPatternCharacters).optional(),
|
|
320
|
+
/** Most matching lines to return. */
|
|
321
|
+
limit: z
|
|
322
|
+
.number()
|
|
323
|
+
.int()
|
|
324
|
+
.min(1)
|
|
325
|
+
.max(maximumListEntries)
|
|
326
|
+
.default(maximumListEntries),
|
|
327
|
+
}));
|
|
328
|
+
/** The lines a search found. */
|
|
329
|
+
export const grepOutputSchema = z.lazy(() => z.strictObject({
|
|
330
|
+
/** The directory that was searched. */
|
|
331
|
+
path: pathSchema,
|
|
332
|
+
/** The matching lines. */
|
|
333
|
+
matches: z
|
|
334
|
+
.array(z.strictObject({
|
|
335
|
+
/** The file that holds the match. */
|
|
336
|
+
path: pathSchema,
|
|
337
|
+
/** The match's line number, counting from 1. */
|
|
338
|
+
line: z.number().int().positive(),
|
|
339
|
+
/** The text of the matching line. */
|
|
340
|
+
text: z.string(),
|
|
341
|
+
}))
|
|
342
|
+
.max(maximumListEntries),
|
|
343
|
+
/** Whether more lines matched than were returned. */
|
|
344
|
+
truncated: z.boolean(),
|
|
345
|
+
}));
|
|
346
|
+
/** A request to replace one piece of text in a file. */
|
|
347
|
+
export const editInputSchema = z.lazy(() => z.strictObject({
|
|
348
|
+
/** The file to edit. */
|
|
349
|
+
path: pathSchema,
|
|
350
|
+
/** The text to replace. It cannot be empty. */
|
|
351
|
+
oldText: boundedTextSchema(1),
|
|
352
|
+
/** The text to put in its place. */
|
|
353
|
+
newText: boundedTextSchema(),
|
|
354
|
+
}));
|
|
355
|
+
/** The reply to a committed edit. */
|
|
356
|
+
export const editOutputSchema = z.lazy(() => z.strictObject({
|
|
357
|
+
/** The file that was edited. */
|
|
358
|
+
path: pathSchema,
|
|
359
|
+
/** Size of the file's content after the edit, in bytes. */
|
|
360
|
+
bytes: z.number().int().nonnegative(),
|
|
361
|
+
/** A readable diff of the change. */
|
|
362
|
+
diff: z.string(),
|
|
363
|
+
/**
|
|
364
|
+
* Line number of the first change in the new text, or null when nothing
|
|
365
|
+
* changed.
|
|
366
|
+
*/
|
|
367
|
+
firstChangedLine: z.number().int().positive().nullable(),
|
|
368
|
+
}));
|
|
369
|
+
// Native Files tools and Computer HTTP decode the same patch contract.
|
|
370
|
+
export { patchInputSchema, patchOutputSchema, } from "@fungi.computer/knapsack/files-patch";
|
|
371
|
+
/** A request to create a directory. */
|
|
372
|
+
export const mkdirInputSchema = z.lazy(() => z.strictObject({
|
|
373
|
+
/** The directory to create. */
|
|
374
|
+
path: pathSchema,
|
|
375
|
+
/**
|
|
376
|
+
* Whether missing parent directories are created too. True when omitted.
|
|
377
|
+
*/
|
|
378
|
+
recursive: z.boolean().default(true),
|
|
379
|
+
}));
|
|
380
|
+
/** The reply to a directory creation. */
|
|
381
|
+
export const mkdirOutputSchema = z.strictObject({
|
|
382
|
+
/** The directory that was created. */
|
|
383
|
+
path: pathSchema,
|
|
384
|
+
});
|
|
385
|
+
/**
|
|
386
|
+
* Host-owned idempotency key for one `exec` or `bash` tool invocation. The
|
|
387
|
+
* host mints it once per invocation and keeps it across its own retries of that
|
|
388
|
+
* invocation; the model never sees or supplies it.
|
|
389
|
+
*/
|
|
390
|
+
export const computerRequestIdSchema = z.uuid({ version: "v7" });
|
|
391
|
+
/** Mint the request ID for one host tool invocation. */
|
|
392
|
+
export function mintComputerRequestId() {
|
|
393
|
+
return computerRequestIdSchema.parse(replayKey());
|
|
394
|
+
}
|
|
395
|
+
/** Model-facing command input. It carries no request identity. */
|
|
396
|
+
function executionInputSchema(budget) {
|
|
397
|
+
return z.strictObject({
|
|
398
|
+
/** The shell command to run. */
|
|
399
|
+
command: z.string().min(1).max(maximumCommandCharacters),
|
|
400
|
+
/** The directory to run in. The Computer's default when omitted. */
|
|
401
|
+
cwd: pathSchema.optional(),
|
|
402
|
+
/** An attached backend id; omitted routes by the Computer's default order. */
|
|
403
|
+
backend: attachedBackendIdSchema.optional(),
|
|
404
|
+
/**
|
|
405
|
+
* How long the command may run, in milliseconds. Ten seconds when omitted.
|
|
406
|
+
*/
|
|
407
|
+
timeoutMs: z
|
|
408
|
+
.number()
|
|
409
|
+
.int()
|
|
410
|
+
.min(1)
|
|
411
|
+
.max(budget.maximumTimeMs)
|
|
412
|
+
.default(10_000),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The model's command input: command, directory, backend and timeout. It
|
|
417
|
+
* carries no request id.
|
|
418
|
+
*/
|
|
419
|
+
export const execInputSchema = executionInputSchema(genericComputerExecutionBudget);
|
|
420
|
+
/** Resource hosts select a budget only after admitting their job principal. */
|
|
421
|
+
export function computerExecutionRequestSchema(budget) {
|
|
422
|
+
return executionInputSchema(budget).extend({
|
|
423
|
+
/** The host's id for this invocation, kept across its own retries. */
|
|
424
|
+
requestId: computerRequestIdSchema,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/** Host-to-Computer command request: the model input plus the host's ID. */
|
|
428
|
+
export const execRequestSchema = execInputSchema.extend({
|
|
429
|
+
/** The host's id for this invocation, kept across its own retries. */
|
|
430
|
+
requestId: computerRequestIdSchema,
|
|
431
|
+
});
|
|
432
|
+
/** One command stream cut at `maximumStreamBytes`; absent streams were whole. */
|
|
433
|
+
export const execTruncationSchema = z.strictObject({
|
|
434
|
+
/** Which stream was cut. */
|
|
435
|
+
stream: z.enum({
|
|
436
|
+
/** The command's standard output. */
|
|
437
|
+
stdout: "stdout",
|
|
438
|
+
/** The command's standard error. */
|
|
439
|
+
stderr: "stderr",
|
|
440
|
+
}),
|
|
441
|
+
/** Bytes of the stream that were kept. */
|
|
442
|
+
keptBytes: z.number().int().nonnegative(),
|
|
443
|
+
/** Bytes the stream held before it was cut. */
|
|
444
|
+
totalBytes: z.number().int().positive(),
|
|
445
|
+
});
|
|
446
|
+
const marker = (stream, kept, total) => {
|
|
447
|
+
const kib = (bytes) => `${(bytes / 1_024).toFixed(1)} KiB`;
|
|
448
|
+
return `[fungi: ${stream} truncated: ${kib(kept)} of ${kib(total)} shown; redirect to a file or use head/tail]`;
|
|
449
|
+
};
|
|
450
|
+
/** The visible line appended to a cut stream; sizes exclude the line itself. */
|
|
451
|
+
export function execTruncationMarker(entry) {
|
|
452
|
+
return `\n${marker(entry.stream, entry.keptBytes, entry.totalBytes)}`;
|
|
453
|
+
}
|
|
454
|
+
/** Matches the marker line `execTruncationMarker` appends at the end of a stream. */
|
|
455
|
+
export const execTruncationMarkerPattern = /\n\[fungi: (?:stdout|stderr) truncated: [^\]]*\]$/u;
|
|
456
|
+
/** Longest prefix of `bytes` within `limit` that ends on a UTF-8 character boundary. */
|
|
457
|
+
export function utf8Prefix(bytes, limit) {
|
|
458
|
+
let end = Math.min(limit, bytes.byteLength);
|
|
459
|
+
while (end > 0 &&
|
|
460
|
+
end < bytes.byteLength &&
|
|
461
|
+
((bytes[end] ?? 0) & 0xc0) === 0x80)
|
|
462
|
+
end -= 1;
|
|
463
|
+
return bytes.slice(0, end);
|
|
464
|
+
}
|
|
465
|
+
/** What a finished command left behind. */
|
|
466
|
+
export const execOutputSchema = z.strictObject({
|
|
467
|
+
/** How the command ended: completed, failed or cancelled. */
|
|
468
|
+
status: z.enum({
|
|
469
|
+
/** The command exited with code 0. */
|
|
470
|
+
completed: "completed",
|
|
471
|
+
/** The command exited with a nonzero code that is not a cancellation. */
|
|
472
|
+
failed: "failed",
|
|
473
|
+
/** The command was cancelled. */
|
|
474
|
+
cancelled: "cancelled",
|
|
475
|
+
}),
|
|
476
|
+
/** The command's exit code. */
|
|
477
|
+
exitCode: z.number().int(),
|
|
478
|
+
/** The captured standard output, cut at `maximumStreamBytes`. */
|
|
479
|
+
stdout: z.string(),
|
|
480
|
+
/** The captured standard error, cut at `maximumStreamBytes`. */
|
|
481
|
+
stderr: z.string(),
|
|
482
|
+
/** `false` when both streams are whole, else one entry per cut stream. */
|
|
483
|
+
truncated: z.union([z.literal(false), z.array(execTruncationSchema).min(1)]),
|
|
484
|
+
});
|
|
485
|
+
/** One long-running container process owned by this Computer. */
|
|
486
|
+
const processIds = defineIdFamily({
|
|
487
|
+
family: "computer-process",
|
|
488
|
+
version: 1,
|
|
489
|
+
kinds: { process: { prefix: "computer_process" } },
|
|
490
|
+
}).process;
|
|
491
|
+
/** Accepts the durable id of one started process. */
|
|
492
|
+
export const computerProcessIdSchema = z.lazy(() => z.string().refine((value) => processIds.is(value)));
|
|
493
|
+
/** At most this many processes may be running in one host scope at once. */
|
|
494
|
+
export const maximumRunningProcesses = 16;
|
|
495
|
+
/** At most this many process records, running or exited, are kept per host scope. */
|
|
496
|
+
export const maximumProcessRecords = 64;
|
|
497
|
+
/** Mint the durable identity of one started process. */
|
|
498
|
+
export function mintComputerProcessId() {
|
|
499
|
+
return processIds.generate();
|
|
500
|
+
}
|
|
501
|
+
/** The host's request to start one long-running process. */
|
|
502
|
+
export const processStartInputSchema = z.strictObject({
|
|
503
|
+
/**
|
|
504
|
+
* The host's replay key. Repeating it within the same scope names the same
|
|
505
|
+
* process.
|
|
506
|
+
*/
|
|
507
|
+
requestId: z.string().min(1).max(256),
|
|
508
|
+
/**
|
|
509
|
+
* The authority the request id belongs to, so ids from different callers do
|
|
510
|
+
* not collide.
|
|
511
|
+
*/
|
|
512
|
+
requestScope: z.string().min(1).max(512),
|
|
513
|
+
/**
|
|
514
|
+
* Container port the process serves. It is admitted for preview when given.
|
|
515
|
+
*/
|
|
516
|
+
port: computerPortSchema.optional(),
|
|
517
|
+
/** The shell command to run. */
|
|
518
|
+
command: z.string().min(1).max(maximumCommandCharacters),
|
|
519
|
+
/** The directory to run in. The Computer's default when omitted. */
|
|
520
|
+
cwd: pathSchema.optional(),
|
|
521
|
+
});
|
|
522
|
+
/** Model input excludes the host's replay identity and authority scope. */
|
|
523
|
+
export const processStartToolInputSchema = processStartInputSchema.omit({
|
|
524
|
+
requestId: true,
|
|
525
|
+
requestScope: true,
|
|
526
|
+
});
|
|
527
|
+
/** A trusted App host binds replay identity to its admitted installation and caller. */
|
|
528
|
+
export function installationProcessScope(installation, method, actor) {
|
|
529
|
+
return processStartInputSchema.shape.requestScope.parse(`installation:${JSON.stringify([installation, method, `${actor.kind}:${actor.id}`])}`);
|
|
530
|
+
}
|
|
531
|
+
/** Names one started process. */
|
|
532
|
+
export const processIdInputSchema = z.strictObject({
|
|
533
|
+
/** The process to act on. */
|
|
534
|
+
id: computerProcessIdSchema,
|
|
535
|
+
});
|
|
536
|
+
/**
|
|
537
|
+
* `running`: computerd still reports the execution live. `exited`: it ended;
|
|
538
|
+
* `exitCode` is null when the runtime that ran it was replaced or its record
|
|
539
|
+
* expired before the exit code was observed. `uncertain`: the Computer could
|
|
540
|
+
* not reach its container to check, so neither is claimed.
|
|
541
|
+
*/
|
|
542
|
+
export const computerProcessStateSchema = z.enum({
|
|
543
|
+
/** computerd still reports the execution live. */
|
|
544
|
+
running: "running",
|
|
545
|
+
/** The execution ended. */
|
|
546
|
+
exited: "exited",
|
|
547
|
+
/** The Computer could not reach its container to check. */
|
|
548
|
+
uncertain: "uncertain",
|
|
549
|
+
});
|
|
550
|
+
/**
|
|
551
|
+
* `synced`: the drain that recorded this exit read all the way to EOF,
|
|
552
|
+
* which is also the read that runs computerd's post-command pull.
|
|
553
|
+
* `pending`: that drain gave up waiting at its own deadline (a
|
|
554
|
+
* backgrounded child, for example `node server.js &`, can hold the
|
|
555
|
+
* transport open long after its parent exits) -- a background
|
|
556
|
+
* continuation keeps watching and upgrades this row to `synced` once it
|
|
557
|
+
* reaches that real EOF, so `pending` rows stay updatable. `unknown`:
|
|
558
|
+
* this exit was recorded with no pull evidence at all (a replaced
|
|
559
|
+
* runtime, an expired record, or any other exit path that never drained
|
|
560
|
+
* for one). Never claim `synced` without the specific evidence for this
|
|
561
|
+
* exit; default to `unknown`, not `synced`.
|
|
562
|
+
*/
|
|
563
|
+
export const computerProcessSyncSchema = z.enum({
|
|
564
|
+
/** The drain that recorded the exit read to the end of the stream. */
|
|
565
|
+
synced: "synced",
|
|
566
|
+
/** The drain gave up at its deadline. A background watcher upgrades it to synced. */
|
|
567
|
+
pending: "pending",
|
|
568
|
+
/** The exit was recorded with no pull evidence. */
|
|
569
|
+
unknown: "unknown",
|
|
570
|
+
});
|
|
571
|
+
/** One started process, as the Computer reports it. */
|
|
572
|
+
export const computerProcessSchema = z.strictObject({
|
|
573
|
+
/** The process's durable id. */
|
|
574
|
+
id: computerProcessIdSchema,
|
|
575
|
+
/** The preview port the process serves, or null. */
|
|
576
|
+
port: computerPortSchema.nullable(),
|
|
577
|
+
/** The command the process runs. */
|
|
578
|
+
command: z.string(),
|
|
579
|
+
/** The directory it was started in, or null for the default. */
|
|
580
|
+
cwd: z.string().nullable(),
|
|
581
|
+
/** Whether the process is running, has exited or cannot be checked. */
|
|
582
|
+
state: computerProcessStateSchema,
|
|
583
|
+
/** Exit code, or null while running or when it was never observed. */
|
|
584
|
+
exitCode: z.number().int().nullable(),
|
|
585
|
+
/**
|
|
586
|
+
* Whether the exit was recorded with evidence that the Computer's files were
|
|
587
|
+
* synchronized.
|
|
588
|
+
*/
|
|
589
|
+
sync: computerProcessSyncSchema,
|
|
590
|
+
/** When the Computer recorded the process as started. */
|
|
591
|
+
startedAt: z.number().int(),
|
|
592
|
+
/** When the Computer recorded the process as ended, or null while it runs. */
|
|
593
|
+
endedAt: z.number().int().nullable(),
|
|
594
|
+
});
|
|
595
|
+
/** The processes started on this Computer. */
|
|
596
|
+
export const processListOutputSchema = z.strictObject({
|
|
597
|
+
/** Process records, at most `maximumProcessRecords`. */
|
|
598
|
+
processes: z.array(computerProcessSchema).max(maximumProcessRecords),
|
|
599
|
+
});
|
|
600
|
+
/** The reply to a request to stop a process. */
|
|
601
|
+
export const processKillOutputSchema = z.lazy(() => z.strictObject({
|
|
602
|
+
/** The process record after the request. */
|
|
603
|
+
process: computerProcessSchema,
|
|
604
|
+
/**
|
|
605
|
+
* Whether a stop signal was sent. False when the process was not running or
|
|
606
|
+
* could not be signalled.
|
|
607
|
+
*/
|
|
608
|
+
signalled: z.boolean(),
|
|
609
|
+
}));
|
|
610
|
+
/** No process with this id was started on this Computer. */
|
|
611
|
+
export class ComputerProcessNotFoundError extends Error {
|
|
612
|
+
constructor(id) {
|
|
613
|
+
super(`Computer process not found: ${id}`);
|
|
614
|
+
this.name = "ComputerProcessNotFoundError";
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** Every process slot is occupied by a running process. */
|
|
618
|
+
export class ComputerProcessLimitError extends Error {
|
|
619
|
+
constructor() {
|
|
620
|
+
super(`Computer process scope can run at most ${String(maximumRunningProcesses)} processes`);
|
|
621
|
+
this.name = "ComputerProcessLimitError";
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Where one command invocation stands: accepted, running, succeeded, failed,
|
|
626
|
+
* unknown (the outcome cannot be established, because the Computer restarted
|
|
627
|
+
* mid-command or the id has no record yet is still inside the admission
|
|
628
|
+
* window) or expired (no record, and older than the admission window).
|
|
629
|
+
*/
|
|
630
|
+
export const computerOperationStatusSchema = z.enum({
|
|
631
|
+
/** The request id is recorded and the command has not started. */
|
|
632
|
+
accepted: "accepted",
|
|
633
|
+
/** The command is running. */
|
|
634
|
+
running: "running",
|
|
635
|
+
/** The command completed. */
|
|
636
|
+
succeeded: "succeeded",
|
|
637
|
+
/** The command ended without completing. */
|
|
638
|
+
failed: "failed",
|
|
639
|
+
/**
|
|
640
|
+
* The outcome cannot be established: the Computer restarted mid-command, or
|
|
641
|
+
* the id has no record yet is still inside the admission window.
|
|
642
|
+
*/
|
|
643
|
+
unknown: "unknown",
|
|
644
|
+
/** The id has no record and is older than the admission window. */
|
|
645
|
+
expired: "expired",
|
|
646
|
+
});
|
|
647
|
+
/** Durable operation status returned to the host that owns the request ID. */
|
|
648
|
+
export const computerOperationResultSchema = z.strictObject({
|
|
649
|
+
/** The host's id for this invocation. */
|
|
650
|
+
requestId: computerRequestIdSchema,
|
|
651
|
+
/** Where the invocation stands. */
|
|
652
|
+
status: computerOperationStatusSchema,
|
|
653
|
+
/** The command's output once known and still retained, otherwise null. */
|
|
654
|
+
output: execOutputSchema.nullable(),
|
|
655
|
+
});
|
|
656
|
+
/** Model-facing command result: the durable status without the host's ID. */
|
|
657
|
+
export const computerToolResultSchema = computerOperationResultSchema.omit({
|
|
658
|
+
requestId: true,
|
|
659
|
+
});
|
|
660
|
+
/** Project one host operation result onto the model-facing tool result. */
|
|
661
|
+
export function computerToolResult(result) {
|
|
662
|
+
return computerToolResultSchema.parse({
|
|
663
|
+
status: result.status,
|
|
664
|
+
output: result.output,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
/** Public operation vocabulary; transport adapters map these to routes. */
|
|
668
|
+
export const computerOperationSchema = z.enum({
|
|
669
|
+
/** Read one whole file as text. */
|
|
670
|
+
"files.read": "files.read",
|
|
671
|
+
/** Read a window of bytes from one file. */
|
|
672
|
+
"files.readBytes": "files.readBytes",
|
|
673
|
+
/** Write one file as text. */
|
|
674
|
+
"files.write": "files.write",
|
|
675
|
+
/** Write one file from binary bytes. */
|
|
676
|
+
"files.writeBytes": "files.writeBytes",
|
|
677
|
+
/** List one page of a directory. */
|
|
678
|
+
"files.list": "files.list",
|
|
679
|
+
/** Find paths that match a glob. */
|
|
680
|
+
"files.glob": "files.glob",
|
|
681
|
+
/** Search file contents. */
|
|
682
|
+
"files.grep": "files.grep",
|
|
683
|
+
/** Replace one piece of text in a file. */
|
|
684
|
+
"files.edit": "files.edit",
|
|
685
|
+
/** Apply a patch to files. */
|
|
686
|
+
"files.patch": "files.patch",
|
|
687
|
+
/** Create a directory. */
|
|
688
|
+
mkdir: "mkdir",
|
|
689
|
+
/** Run a command in the Computer's container. */
|
|
690
|
+
bash: "bash",
|
|
691
|
+
/** Run a command in the Computer's lightweight Worker shell. */
|
|
692
|
+
exec: "exec",
|
|
693
|
+
});
|
|
694
|
+
/** The expected file contents no longer match at atomic replacement. */
|
|
695
|
+
export class ComputerFileConflict extends Error {
|
|
696
|
+
constructor() {
|
|
697
|
+
super("Files edit conflict: contents changed before replacement");
|
|
698
|
+
this.name = "ComputerFileConflict";
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/** A byte window starts beyond the current file extent. */
|
|
702
|
+
export class ComputerFileRangeError extends Error {
|
|
703
|
+
size;
|
|
704
|
+
constructor(
|
|
705
|
+
/** The file's current size in bytes. */
|
|
706
|
+
size) {
|
|
707
|
+
super("Byte offset exceeds file size");
|
|
708
|
+
this.size = size;
|
|
709
|
+
this.name = "ComputerFileRangeError";
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/** The requested file bytes exceed the admitted read bound. */
|
|
713
|
+
export class ComputerFileTooLargeError extends Error {
|
|
714
|
+
constructor() {
|
|
715
|
+
super("File exceeds the Computer read bound");
|
|
716
|
+
this.name = "ComputerFileTooLargeError";
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function capability(id, kind, description, input, output) {
|
|
720
|
+
const parameters = structuredClone(z.toJSONSchema(input, { io: "input" }));
|
|
721
|
+
const result = structuredClone(z.toJSONSchema(output));
|
|
722
|
+
return computerValueSchema.parse({
|
|
723
|
+
id,
|
|
724
|
+
name: id,
|
|
725
|
+
kind,
|
|
726
|
+
description,
|
|
727
|
+
parameters,
|
|
728
|
+
output: result,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
let capabilities;
|
|
732
|
+
/**
|
|
733
|
+
* The operations every Computer advertises; static, so hosts can describe them
|
|
734
|
+
* without a call. Built on first read: deriving JSON Schema for every operation
|
|
735
|
+
* is real work that a Worker should not pay at startup.
|
|
736
|
+
*/
|
|
737
|
+
export function computerCapabilities() {
|
|
738
|
+
capabilities ??= computerCapabilitiesSchema.parse([
|
|
739
|
+
capability("read", "fileStore", "Read one bounded UTF-8 file from this Computer.", readInputSchema, readOutputSchema),
|
|
740
|
+
capability("list", "fileStore", "List one bounded page of a directory on this Computer.", listInputSchema, listOutputSchema),
|
|
741
|
+
capability("mkdir", "fileStore", "Create a directory on this Computer.", mkdirInputSchema, mkdirOutputSchema),
|
|
742
|
+
capability("write", "fileStore", "Write one bounded UTF-8 file without creating its parent directory.", writeInputSchema, writeOutputSchema),
|
|
743
|
+
capability("bash", "runtime", "Run one bounded shell command in this Computer's Linux container, with node, npm and the programs installed there.", execInputSchema, computerToolResultSchema),
|
|
744
|
+
capability("exec", "runtime", "Run one bounded command in a lightweight Worker shell over this Computer's files. It has no node, npm or installed programs; use bash to run programs.", execInputSchema, computerToolResultSchema),
|
|
745
|
+
capability("start_process", "runtime", "Start one long-running command, such as a dev server, in this Computer's container. Returns its durable process id.", processStartToolInputSchema, computerProcessSchema),
|
|
746
|
+
capability("list_processes", "runtime", "List this Computer's started processes as running, exited or uncertain.", z.strictObject({}), processListOutputSchema),
|
|
747
|
+
capability("kill_process", "runtime", "Send SIGTERM to one running process on this Computer.", processIdInputSchema, processKillOutputSchema),
|
|
748
|
+
]);
|
|
749
|
+
return capabilities;
|
|
750
|
+
}
|