mandala-computer-mcp 0.1.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +544 -0
  3. package/dist/api.d.ts +186 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +932 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +292 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/errors.d.ts +560 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +873 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/events.d.ts +406 -0
  16. package/dist/events.d.ts.map +1 -0
  17. package/dist/events.js +1679 -0
  18. package/dist/events.js.map +1 -0
  19. package/dist/format.d.ts +125 -0
  20. package/dist/format.d.ts.map +1 -0
  21. package/dist/format.js +180 -0
  22. package/dist/format.js.map +1 -0
  23. package/dist/http.d.ts +46 -0
  24. package/dist/http.d.ts.map +1 -0
  25. package/dist/http.js +792 -0
  26. package/dist/http.js.map +1 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +12 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/paths.d.ts +394 -0
  32. package/dist/paths.d.ts.map +1 -0
  33. package/dist/paths.js +677 -0
  34. package/dist/paths.js.map +1 -0
  35. package/dist/server.d.ts +18 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +97 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/session.d.ts +78 -0
  40. package/dist/session.d.ts.map +1 -0
  41. package/dist/session.js +166 -0
  42. package/dist/session.js.map +1 -0
  43. package/dist/stdio.d.ts +11 -0
  44. package/dist/stdio.d.ts.map +1 -0
  45. package/dist/stdio.js +43 -0
  46. package/dist/stdio.js.map +1 -0
  47. package/dist/tools/agent.d.ts +16 -0
  48. package/dist/tools/agent.d.ts.map +1 -0
  49. package/dist/tools/agent.js +147 -0
  50. package/dist/tools/agent.js.map +1 -0
  51. package/dist/tools/computers.d.ts +3 -0
  52. package/dist/tools/computers.d.ts.map +1 -0
  53. package/dist/tools/computers.js +1037 -0
  54. package/dist/tools/computers.js.map +1 -0
  55. package/dist/tools/events.d.ts +3 -0
  56. package/dist/tools/events.d.ts.map +1 -0
  57. package/dist/tools/events.js +1077 -0
  58. package/dist/tools/events.js.map +1 -0
  59. package/dist/tools/guest.d.ts +3 -0
  60. package/dist/tools/guest.d.ts.map +1 -0
  61. package/dist/tools/guest.js +761 -0
  62. package/dist/tools/guest.js.map +1 -0
  63. package/dist/tools/input.d.ts +3 -0
  64. package/dist/tools/input.d.ts.map +1 -0
  65. package/dist/tools/input.js +240 -0
  66. package/dist/tools/input.js.map +1 -0
  67. package/dist/tools/snapshots.d.ts +3 -0
  68. package/dist/tools/snapshots.d.ts.map +1 -0
  69. package/dist/tools/snapshots.js +333 -0
  70. package/dist/tools/snapshots.js.map +1 -0
  71. package/dist/tools/templates.d.ts +3 -0
  72. package/dist/tools/templates.d.ts.map +1 -0
  73. package/dist/tools/templates.js +492 -0
  74. package/dist/tools/templates.js.map +1 -0
  75. package/dist/tools/types.d.ts +18 -0
  76. package/dist/tools/types.d.ts.map +1 -0
  77. package/dist/tools/types.js +2 -0
  78. package/dist/tools/types.js.map +1 -0
  79. package/dist/tools/webhooks.d.ts +3 -0
  80. package/dist/tools/webhooks.d.ts.map +1 -0
  81. package/dist/tools/webhooks.js +260 -0
  82. package/dist/tools/webhooks.js.map +1 -0
  83. package/package.json +59 -0
package/dist/paths.js ADDED
@@ -0,0 +1,677 @@
1
+ /**
2
+ * Every path this server can reach, and every body it can send.
3
+ *
4
+ * Built in one place for the reason mandala-computer-python builds them in one
5
+ * place: the surface test pins what this server calls against the platform's
6
+ * `V1_ROUTES` allowlist, and a URL assembled at a call site is a URL that test
7
+ * cannot see. Anything absent from the allowlist is a 404 in a user's hands
8
+ * rather than a failure here.
9
+ */
10
+ export const TEMPLATES = 'templates';
11
+ /** The JSON Schema for a `mandala/v1` document (platform OPL-3568). */
12
+ export const TEMPLATE_SCHEMA = 'templates/schema';
13
+ /** Check a document without publishing it. Side-effect free, and claims no ref. */
14
+ export const TEMPLATE_VALIDATE = 'templates/validate';
15
+ /**
16
+ * Every build this account has started (platform OPL-3791).
17
+ *
18
+ * A collection, like {@link MOVES} and for the same reason: a build is a job
19
+ * rather than a property of a computer, and it outlives the request that
20
+ * started it.
21
+ */
22
+ export const BUILDS = 'builds';
23
+ export const SIZES = 'sizes';
24
+ export const COMPUTERS = 'computers';
25
+ export const SNAPSHOTS = 'snapshots';
26
+ /**
27
+ * Every move on the account, live and recently finished (OPL-3766).
28
+ *
29
+ * A collection and not `computers/:id/move`, which is the platform's own
30
+ * decision and worth knowing here: a per-computer read could not tell a computer
31
+ * with no move from an id that does not exist, so there is no such route. The
32
+ * poll filters this by `computer_id`.
33
+ */
34
+ export const MOVES = 'moves';
35
+ /** What the account has used, over a window. Account-scoped, like {@link MOVES}. */
36
+ export const USAGE = 'usage';
37
+ /**
38
+ * How long automatic snapshots are kept — the plan's retention window.
39
+ *
40
+ * Account-scoped like {@link USAGE} and {@link MOVES}, and answered by the
41
+ * control plane rather than by a hypervisor, so it cannot come back short the
42
+ * way a fleet listing can. Read-only: the plan owns retention, and there is no
43
+ * write on any surface.
44
+ */
45
+ export const RETENTION = 'retention';
46
+ /**
47
+ * The account's webhook subscriptions (platform OPL-4300; OPL-4306 here).
48
+ *
49
+ * Account-scoped like {@link USAGE}: a subscription receives events from many
50
+ * computers, so it belongs to the account and not to any of them. Answered by
51
+ * the control plane from its own tables, never by a hypervisor.
52
+ */
53
+ export const WEBHOOKS = 'webhooks';
54
+ /**
55
+ * An RFC 3339 timestamp WITH a time zone, which is the only kind `GET /usage`
56
+ * takes.
57
+ *
58
+ * Checked here rather than left to the platform, because the mistake this
59
+ * catches does not look like one. `2026-08-01T00:00:00` has no zone, and the
60
+ * zone that would have to be assumed is the server's rather than the caller's —
61
+ * so a lenient reading does not fail, it answers a window shifted by however
62
+ * many hours, on the one call whose output somebody compares against a bill. A
63
+ * model writing a date by hand is exactly the caller that produces this.
64
+ */
65
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
66
+ /** The window to ask about, or undefined for the account's billing period. */
67
+ export function usageQuery(from, to) {
68
+ const query = {};
69
+ for (const [name, value] of [
70
+ ['from', from],
71
+ ['to', to],
72
+ ]) {
73
+ if (value === undefined)
74
+ continue;
75
+ if (!RFC3339.test(value)) {
76
+ throw new Error(`${name} must be an RFC 3339 timestamp with a time zone, e.g. 2026-08-01T00:00:00Z — ` +
77
+ `got ${JSON.stringify(value)}`);
78
+ }
79
+ query[name] = value;
80
+ }
81
+ // `to` alone is measured from the start of the CURRENT billing period, not
82
+ // from the start of the one `to` falls in, so it answers a window the caller
83
+ // did not ask for rather than failing. Both descriptions say this is refused;
84
+ // this is where it is refused.
85
+ if (to !== undefined && from === undefined) {
86
+ throw new Error('to on its own is measured from the current billing period rather than from the period it ' +
87
+ 'names — send from as well to ask about a window that has closed, or omit both for this period');
88
+ }
89
+ return Object.keys(query).length ? query : undefined;
90
+ }
91
+ /**
92
+ * One path segment, checked before it is encoded.
93
+ *
94
+ * `encodeURIComponent` leaves `.` alone — it is unreserved, so an id of `..`
95
+ * survives it byte for byte — and `new URL` then resolves the dot segment away:
96
+ * `computers/../exec` becomes `/api/v1/exec`, a different route than the tool
97
+ * asked for, reached with the caller's key and reported as whatever that route
98
+ * answers. An id is opaque and never legitimately contains a slash or is a bare
99
+ * run of dots, so those are refused here rather than encoded into something
100
+ * that normalises later.
101
+ */
102
+ function segment(kind, value) {
103
+ const v = value.trim();
104
+ if (!v)
105
+ throw new Error(`${kind} must not be empty`);
106
+ if (/^\.+$/.test(v))
107
+ throw new Error(`${kind} must not be '${v}'`);
108
+ if (v.includes('/') || v.includes('\\')) {
109
+ throw new Error(`${kind} must not contain a slash: ${v}`);
110
+ }
111
+ return encodeURIComponent(v);
112
+ }
113
+ export const computer = (id) => `computers/${segment('computer_id', id)}`;
114
+ /**
115
+ * start | stop | suspend | restart | clone | screenshot | input | exec |
116
+ * windows | files | snapshots | schedule | agent
117
+ */
118
+ export const computerAction = (id, action) => `${computer(id)}/${action}`;
119
+ /** A background command's guest pid (OPL-3584). */
120
+ export const execHandle = (id, pid) => {
121
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
122
+ throw new Error(`pid must be a positive safe integer: ${pid}`);
123
+ }
124
+ return `${computer(id)}/exec/${pid}`;
125
+ };
126
+ /** One window on the desktop (OPL-3583). The id is `0x2600003`-shaped. */
127
+ export const window_ = (id, windowId) => `${computer(id)}/windows/${segment('window_id', windowId)}`;
128
+ /**
129
+ * One published template, by the two halves of its ref (platform OPL-3789).
130
+ *
131
+ * Two segments and not one, because that is the shape of the route: the
132
+ * platform reduces `templates/<a>/<b>` to `templates/:namespace/:name`, so a
133
+ * ref handed over whole — `acc-1/devbox@1.0.0` — would be percent-encoded into
134
+ * one segment and reach a route that does not exist. The version is a QUERY
135
+ * parameter on this path, not part of it; see {@link templateVersionQuery}.
136
+ */
137
+ export const templateRef = (namespace, name) => `${TEMPLATES}/${segment('namespace', namespace)}/${segment('name', name)}`;
138
+ /** MAJOR.MINOR.PATCH, no leading zeros — the platform's own version grammar. */
139
+ const VERSION = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
140
+ /**
141
+ * The `version` query parameter, refused when it is not a version.
142
+ *
143
+ * Absence and emptiness have to be different things here, and a model is the
144
+ * caller most likely to conflate them. The platform answers 400 for a version
145
+ * that is empty or malformed rather than defaulting, and that refusal exists
146
+ * because of a real defect: `?version=` read as "no version was named" and
147
+ * retired an entire template, irreversibly.
148
+ *
149
+ * This server cannot send that. `undefined` omits the parameter — which on a
150
+ * retire means EVERY version — and anything else has to be a version. A model
151
+ * that passes an empty string is told what a version looks like, before
152
+ * anything is deleted.
153
+ */
154
+ export function templateVersionQuery(version) {
155
+ if (version === undefined)
156
+ return {};
157
+ if (!VERSION.test(version)) {
158
+ throw new Error(`version must be MAJOR.MINOR.PATCH with no leading zeros (got ${JSON.stringify(version)}). ` +
159
+ `Omit it entirely to name the whole template.`);
160
+ }
161
+ return { version };
162
+ }
163
+ /**
164
+ * The document a publish, a validate or a build sends.
165
+ *
166
+ * Raw bytes, not a JSON envelope: the platform reads JSON or YAML off the body
167
+ * itself, so a wrapper would be a document the validator never sees — and one
168
+ * that parses, so the failure would be a complaint about the wrapper's fields.
169
+ */
170
+ export function templateDocument(document) {
171
+ if (typeof document !== 'string' || !document.trim()) {
172
+ throw new Error('document must be a non-empty template document, as JSON or YAML');
173
+ }
174
+ return new TextEncoder().encode(document);
175
+ }
176
+ export const build = (id) => `${BUILDS}/${segment('build_id', id)}`;
177
+ /** progress | events */
178
+ export const buildAction = (id, action) => `${build(id)}/${action}`;
179
+ /**
180
+ * `no_reuse`, sent only when it is asked for.
181
+ *
182
+ * Omitted rather than sent as `false`, and the reason is the documented schema
183
+ * rather than a claim about the parser: lib/apidoc gives this parameter
184
+ * `enum: ['true']`, so `true` is the only value the reference admits.
185
+ *
186
+ * This said the platform reads the key's PRESENCE, which is false —
187
+ * server/buildjob.go reads `Get("no_reuse") == "true"`. The request was right
188
+ * either way; the stated reason was not (/code-review, OPL-3835).
189
+ */
190
+ export const buildQuery = (noReuse) => noReuse ? { no_reuse: 'true' } : {};
191
+ export const snapshot = (id) => `snapshots/${segment('snapshot_id', id)}`;
192
+ /** restore | clone */
193
+ export const snapshotAction = (id, action) => `${snapshot(id)}/${action}`;
194
+ /** Drop the keys a caller did not set, rather than sending them as null.
195
+ *
196
+ * Omission is meaningful on create: the platform applies the template's
197
+ * defaults only where a key is absent, so an explicit null overrides a good
198
+ * default with nothing.
199
+ */
200
+ export function omitUndefined(body) {
201
+ return Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined));
202
+ }
203
+ export function createBody(args) {
204
+ const { start = true, ...rest } = args;
205
+ return { ...omitUndefined(rest), start };
206
+ }
207
+ /**
208
+ * The shell command that puts a URL on the guest's screen.
209
+ *
210
+ * The browser is named rather than asked for, and the reasoning is the platform
211
+ * SDK's: Firefox by name, not `xdg-open` or one of the other portable wrappers.
212
+ * Naming it keeps the choice in one place — this function is the only thing that
213
+ * decides which browser the guest opens, so a change of image, or of which
214
+ * browser we want, is a change here rather than in every prompt that ever asked
215
+ * for a browser.
216
+ */
217
+ export function openUrlCommand(url) {
218
+ const trimmed = url.trim();
219
+ if (!trimmed)
220
+ throw new Error('url must not be empty');
221
+ // Quoting stops the URL reaching the shell as anything but one argument. It
222
+ // cannot stop the browser reading a leading dash as a flag, and no URL starts
223
+ // with one, so that is refused outright rather than quoted.
224
+ if (trimmed.startsWith('-'))
225
+ throw new Error(`url must not start with '-': ${trimmed}`);
226
+ return `nohup firefox ${shellQuote(trimmed)} >/dev/null 2>&1 &`;
227
+ }
228
+ /** POSIX single-quoting: the only characters that survive are the ones inside. */
229
+ export function shellQuote(s) {
230
+ return `'${s.replaceAll("'", `'\\''`)}'`;
231
+ }
232
+ export function execBody(args) {
233
+ // A NUL ends a C string, and the command is the one field on this route that
234
+ // reaches the guest without anything checking it. `execEnv` below refuses one
235
+ // for this exact reason, and the platform refuses one in `cwd` and in every
236
+ // file path — `validGuestPath` rejects the whole control range — but its only
237
+ // test on `command` is that it is not empty (server/api.go). So a command
238
+ // carrying a NUL is truncated at the guest's argv boundary: a shorter command
239
+ // than the caller wrote runs, and its exit code is reported as an ordinary
240
+ // success. The same shape as the surrogate refusals — a call that works and
241
+ // does something other than what was asked.
242
+ if (args.command.includes('\0')) {
243
+ throw new Error('command must not contain a NUL: a NUL ends a C string, so the guest would run only the part ' +
244
+ 'in front of it and report that as a success.');
245
+ }
246
+ const body = { command: args.command };
247
+ if (args.timeout_s !== undefined)
248
+ body.timeout_s = args.timeout_s;
249
+ // Omitted rather than sent empty: the platform's default is the system
250
+ // context, and "desktop" is the only other value it accepts.
251
+ if (args.desktop)
252
+ body.session = 'desktop';
253
+ if (args.background)
254
+ body.background = true;
255
+ if (args.cwd)
256
+ body.cwd = args.cwd;
257
+ const env = execEnv(args.env);
258
+ if (env)
259
+ body.env = env;
260
+ return body;
261
+ }
262
+ /**
263
+ * The environment for one command, refused here rather than at the platform.
264
+ *
265
+ * An empty object is dropped rather than sent, for the reason `desktop` is
266
+ * dropped: `execEnvList` returns nil for `len(env) == 0`, so an empty object and
267
+ * an absent one are already the same request, and the one that says so in fewer
268
+ * fields is the one worth sending.
269
+ *
270
+ * The three rules below are checked in this process because the platform checks
271
+ * them AFTER it has resumed the computer. `POST computers/:id/exec` runs its
272
+ * `use` — which wakes a suspended guest and bills the resume — before `Exec`
273
+ * ever looks at the environment, so a malformed name costs a machine coming up
274
+ * to be told no. They are also the rules that will not drift: they are what an
275
+ * entry IS in the list the guest agent takes, `NAME=value` strings separated by
276
+ * NULs, rather than policy. The ceilings on how many entries and how long one
277
+ * may be (64 and 4096 bytes, execbg.go) are policy, are the platform's to
278
+ * change, and are deliberately not repeated here.
279
+ *
280
+ * The `=` case is the one a model actually gets wrong. `{'FOO=bar': ''}` is the
281
+ * assignment written into the name, which is the same mistake as writing the
282
+ * assignment into the command line — so the refusal says the shape rather than
283
+ * just the rule.
284
+ */
285
+ export function execEnv(env) {
286
+ if (!env)
287
+ return undefined;
288
+ const entries = Object.entries(env);
289
+ if (entries.length === 0)
290
+ return undefined;
291
+ for (const [name, value] of entries) {
292
+ if (!name)
293
+ throw new Error('env has an entry with an empty name');
294
+ if (name.includes('=')) {
295
+ throw new Error(`env name must not contain '=': ${name}. The name and the value are separate here — pass {FOO: 'bar'}, not {'FOO=bar': ''}.`);
296
+ }
297
+ // A NUL ends a C string, so the guest agent would take the half in front of
298
+ // it and drop the rest without saying so.
299
+ if (name.includes('\0') || value.includes('\0')) {
300
+ throw new Error(`env entry ${name} must not contain a NUL`);
301
+ }
302
+ }
303
+ return Object.fromEntries(entries);
304
+ }
305
+ // --- input ----------------------------------------------------------------
306
+ //
307
+ // The verb set is Anthropic's computer tool, in full, because the platform's
308
+ // /input endpoint speaks it and accepts both that vocabulary and a flatter one.
309
+ // Which spelling each body uses below is chosen for whichever is unambiguous —
310
+ // see the note on scroll, where the two genuinely differ in meaning.
311
+ export const MODIFIER_JOIN = '+';
312
+ export function pointerBody(action, x, y) {
313
+ return { action, x, y };
314
+ }
315
+ /**
316
+ * Half a coordinate, refused rather than completed with a zero.
317
+ *
318
+ * Same reasoning as `dragBody` below, which has always refused half an origin:
319
+ * a caller naming only `y` meant to name a point, and quietly filling `x` with
320
+ * 0 sends the pointer to the edge of the screen while the tool reports acting
321
+ * "where the pointer was". The action succeeds, at the wrong place, and nothing
322
+ * says so.
323
+ */
324
+ function wholePoint(x, y) {
325
+ if ((x === undefined) !== (y === undefined)) {
326
+ throw new Error('give both x and y, or neither — half a coordinate is not a point');
327
+ }
328
+ }
329
+ /**
330
+ * A click, optionally at a point and optionally with keys held down.
331
+ *
332
+ * No coordinate means "where the pointer already is", which is a real and
333
+ * different request from clicking (0, 0) — the corner of the screen. So the
334
+ * keys are omitted rather than sent as zeros; the platform carries that
335
+ * distinction all the way down to `inputWire` in server/input.go.
336
+ */
337
+ export function clickBody(action, x, y, modifiers = []) {
338
+ wholePoint(x, y);
339
+ const body = { action };
340
+ if (x !== undefined && y !== undefined) {
341
+ body.x = x;
342
+ body.y = y;
343
+ }
344
+ if (modifiers.length)
345
+ body.text = modifiers.join(MODIFIER_JOIN);
346
+ return body;
347
+ }
348
+ /**
349
+ * A press, a move and a release — one gesture, not two clicks.
350
+ *
351
+ * Half an origin is refused here rather than dropped. A drag naming only
352
+ * `from_x` reads as a caller who meant to give a starting point, and silently
353
+ * ignoring the half they gave produces a drag that succeeds while selecting a
354
+ * different region: the worst shape a mistake can take, because nothing reports
355
+ * it.
356
+ */
357
+ export function dragBody(toX, toY, fromX, fromY) {
358
+ if ((fromX === undefined) !== (fromY === undefined)) {
359
+ throw new Error('give both from_x and from_y, or neither');
360
+ }
361
+ const body = { action: 'left_click_drag', coordinate: [toX, toY] };
362
+ if (fromX !== undefined && fromY !== undefined)
363
+ body.start_coordinate = [fromX, fromY];
364
+ return body;
365
+ }
366
+ export function buttonBody(action, x, y) {
367
+ wholePoint(x, y);
368
+ const body = { action };
369
+ if (x !== undefined && y !== undefined) {
370
+ body.x = x;
371
+ body.y = y;
372
+ }
373
+ return body;
374
+ }
375
+ export const SCROLL_DIRECTIONS = ['up', 'down', 'left', 'right'];
376
+ /**
377
+ * A wheel scroll, optionally at a point and optionally with keys held.
378
+ *
379
+ * `coordinate` and not the flat pair, and that is not a style choice. The
380
+ * platform reads a flat `x:0, y:0` on a scroll as "no position" — it has to,
381
+ * because that is what every defaulted scroll sent before the keys became
382
+ * optional — so a caller who genuinely means the top-left corner cannot say so
383
+ * that way. `coordinate` has no such history.
384
+ */
385
+ export function scrollBody(args) {
386
+ const body = {
387
+ action: 'scroll',
388
+ scroll_direction: args.direction,
389
+ amount: args.amount,
390
+ };
391
+ wholePoint(args.x, args.y);
392
+ if (args.x !== undefined && args.y !== undefined) {
393
+ body.coordinate = [args.x, args.y];
394
+ }
395
+ if (args.modifiers?.length)
396
+ body.text = args.modifiers.join(MODIFIER_JOIN);
397
+ return body;
398
+ }
399
+ /**
400
+ * Text typed at the keyboard, checked the way the clipboard's is.
401
+ *
402
+ * The same silent corruption {@link clipboardBody} refuses and `write_file`
403
+ * refuses, on the third path that carries a caller's own characters into the
404
+ * guest: `JSON.stringify` escapes a lone surrogate and Go's `encoding/json`
405
+ * decodes it to U+FFFD.
406
+ *
407
+ * What happens NEXT is this route's own, and it is worth stating precisely
408
+ * rather than borrowing the clipboard's sentence. The platform's `type` handler
409
+ * walks runes and looks each one up in `charKeys` — a printable-ASCII map — and
410
+ * `continue`s past anything it does not find (server/input.go). So the
411
+ * replacement character is not typed onto the desktop; it is DROPPED, and the
412
+ * tool goes on to report `Typed N character(s).` counting the character that
413
+ * never arrived. Either way the screen does not hold what the caller asked for
414
+ * and the answer says it does, which is the thing worth refusing. NOT extended
415
+ * to a NUL for the same reason: the platform skips an unmappable rune rather
416
+ * than truncating the string at it, so a NUL is no more special there than any
417
+ * other unmappable character, and singling it out would be a rule about one
418
+ * codepoint dressed as a rule about corruption (/code-review, OPL-4244).
419
+ */
420
+ export function typeBody(text) {
421
+ if (hasUnpairedSurrogate(text))
422
+ throw new Error(typedSurrogateRefusal);
423
+ return { action: 'type', text };
424
+ }
425
+ const typedSurrogateRefusal = 'that text has an unpaired surrogate in it — half of a character, usually from a string cut ' +
426
+ 'through the middle of an emoji. It is not valid UTF-8: it reaches the guest as a replacement ' +
427
+ 'character, which is not a key the desktop can type, so it would be dropped and the count ' +
428
+ 'reported back would include it. Nothing was typed. Send the whole character, or cut the text ' +
429
+ 'on a character boundary.';
430
+ export function keyBody(keys, holdSeconds) {
431
+ if (!keys.length)
432
+ throw new Error('press_key needs at least one key');
433
+ if (holdSeconds === undefined)
434
+ return { action: 'key', keys };
435
+ if (holdSeconds <= 0)
436
+ throw new Error('hold_seconds must be positive');
437
+ return { action: 'hold_key', keys, duration: holdSeconds };
438
+ }
439
+ /**
440
+ * A pause inside the guest.
441
+ *
442
+ * Capped at 30 seconds by the platform, and asking for longer is refused rather
443
+ * than truncated — a wait here is a held HTTP request crossing a reverse proxy,
444
+ * and 100 seconds would not return, it would fail. The cap is checked here too
445
+ * so a model learns the limit from the tool's own error instead of spending a
446
+ * round trip on it.
447
+ */
448
+ export function waitBody(seconds) {
449
+ if (seconds <= 0)
450
+ throw new Error('seconds must be positive');
451
+ if (seconds > 30)
452
+ throw new Error('the platform caps a wait at 30 seconds; ask again to wait longer');
453
+ return { action: 'wait', duration: seconds };
454
+ }
455
+ export const cursorBody = () => ({ action: 'cursor_position' });
456
+ // --- windows, snapshots, schedule -----------------------------------------
457
+ export const WINDOW_ACTIONS = [
458
+ 'focus',
459
+ 'raise',
460
+ 'minimize',
461
+ 'maximize',
462
+ 'unmaximize',
463
+ 'close',
464
+ 'move',
465
+ 'resize',
466
+ ];
467
+ /**
468
+ * The body for a window action, with the arguments that action needs.
469
+ *
470
+ * Checked here for the reason `wholePoint` is: the window manager places the
471
+ * frame where it likes and applications snap to their own grid, so a move that
472
+ * arrived with half a coordinate does not come back looking wrong — it comes
473
+ * back looking like the window manager's usual approximation. A `resize` with
474
+ * neither dimension is the same shape of failure, one the platform would have
475
+ * to guess its way out of.
476
+ */
477
+ export function windowBody(args) {
478
+ if (args.action === 'move' && (args.x === undefined || args.y === undefined)) {
479
+ throw new Error('move needs both x and y — half a coordinate is not a place');
480
+ }
481
+ if (args.action === 'resize' && args.width === undefined && args.height === undefined) {
482
+ throw new Error('resize needs width, height, or both');
483
+ }
484
+ if (args.action === 'move')
485
+ return { action: args.action, x: args.x, y: args.y };
486
+ if (args.action === 'resize') {
487
+ return omitUndefined({ action: args.action, width: args.width, height: args.height });
488
+ }
489
+ return { action: args.action };
490
+ }
491
+ /**
492
+ * The most text `PUT /computers/{id}/clipboard` carries INTO a guest, in bytes.
493
+ *
494
+ * Mirrored so a request that can only fail is not made. NOT machine-checked —
495
+ * `scripts/check-surface.mjs` reads the platform's `web/lib`, and this number
496
+ * lives in its `server/clipboard.go` as `clipboardWriteMax`. It is not
497
+ * arbitrary: the platform puts the text inside one argument of one command,
498
+ * Linux caps a single argv string at 128 KiB, and two layers of base64 stand
499
+ * between the text and that ceiling, so each byte costs about 1.8 of it. Past
500
+ * the cap `execve` fails with E2BIG rather than truncating.
501
+ *
502
+ * The READ cap is 128 KiB, a different bound on a different channel, and is
503
+ * deliberately not mirrored: nothing here can meet it, since that text comes
504
+ * from the guest.
505
+ */
506
+ export const MAX_CLIPBOARD_BYTES = 64 * 1024;
507
+ /**
508
+ * Said in full, because the model reading it did not choose those bytes on
509
+ * purpose and cannot see them: a lone surrogate arrives from a string that was
510
+ * cut in the middle of an emoji, or from JSON that carried one escaped.
511
+ */
512
+ const surrogateRefusal = 'that text has an unpaired surrogate in it — half of a character, usually from a string cut ' +
513
+ 'through the middle of an emoji. It is not valid UTF-8, and sending it would put a replacement ' +
514
+ 'character on the desktop rather than what you meant, so nothing was written. Send the whole ' +
515
+ 'character, or cut the text on a character boundary.';
516
+ /**
517
+ * Whether a string carries half a character.
518
+ *
519
+ * Its own predicate because there is more than one way into this failure and
520
+ * they do not share a refusal sentence. The clipboard's says "on the desktop";
521
+ * `write_file` puts the same broken byte into a file on a disk. What they DO
522
+ * share is the check, and that is the part worth having in one place — a second
523
+ * hand-rolled surrogate walk is a second chance to get the pairing backwards.
524
+ *
525
+ * Walked in UTF-16 code units, because that is what the flaw is made of: a high
526
+ * surrogate is legal where a low one follows it and is half a character
527
+ * anywhere else. `for...of` would not do — it yields the lone surrogate as a
528
+ * string and hides which half it was.
529
+ */
530
+ export function hasUnpairedSurrogate(text) {
531
+ for (let i = 0; i < text.length; i++) {
532
+ const unit = text.charCodeAt(i);
533
+ if (unit >= 0xd800 && unit <= 0xdbff) {
534
+ const next = text.charCodeAt(i + 1);
535
+ if (next >= 0xdc00 && next <= 0xdfff) {
536
+ i++;
537
+ continue;
538
+ }
539
+ return true;
540
+ }
541
+ if (unit >= 0xdc00 && unit <= 0xdfff)
542
+ return true;
543
+ }
544
+ return false;
545
+ }
546
+ /**
547
+ * The body for a clipboard write, checked before it costs a round trip.
548
+ *
549
+ * Two of these refusals are the platform's own, mirrored so a call that can only
550
+ * fail is not spent. The NUL is the one worth explaining: the platform confirms
551
+ * a write by reading the selection back through a command substitution, and a
552
+ * shell truncates that at the first NUL — so the write would land, the read-back
553
+ * would disagree, and the answer would be a 409 inviting a retry at something
554
+ * that had already worked.
555
+ *
556
+ * The UNPAIRED SURROGATE is not the platform's, and that is exactly why it is
557
+ * here. Nothing refuses it anywhere on the path: `JSON.stringify` escapes the
558
+ * lone code unit, Go's `encoding/json` decodes it to U+FFFD, and the desktop
559
+ * ends up holding a replacement character where a caller's text was. A write
560
+ * that succeeds with different text than was asked for is worse than one that
561
+ * fails, and this is the last place that can tell the difference — `TextEncoder`
562
+ * below has already made the substitution, which is why the scan runs before it.
563
+ * The other two clients refuse it too (mandala-computer-typescript's
564
+ * `clipboardBody`, and mandala-computer-python, where `str.encode` raises).
565
+ *
566
+ * The cap is counted in UTF-8 BYTES rather than characters. An emoji is four of
567
+ * them, so a `text.length` check would pass four times the legal payload to an
568
+ * execve that answers E2BIG.
569
+ */
570
+ export function clipboardBody(text) {
571
+ if (!text)
572
+ throw new Error('there is no text to put on the clipboard');
573
+ if (text.includes('\0')) {
574
+ throw new Error('that text has a NUL byte in it, which a clipboard cannot carry');
575
+ }
576
+ if (hasUnpairedSurrogate(text))
577
+ throw new Error(surrogateRefusal);
578
+ const bytes = new TextEncoder().encode(text).length;
579
+ if (bytes > MAX_CLIPBOARD_BYTES) {
580
+ throw new Error(`that is ${bytes} bytes of text; the clipboard takes at most ${MAX_CLIPBOARD_BYTES}`);
581
+ }
582
+ return { text };
583
+ }
584
+ /**
585
+ * The body for a capture: what to include, and what to call it.
586
+ *
587
+ * An args object rather than the bare boolean it was, matching `createBody` and
588
+ * `execBody` next to it — `memory` was never going to be the only thing this
589
+ * route took, and a second positional boolean is the kind of call site nobody
590
+ * reads correctly.
591
+ *
592
+ * A name that is only whitespace is dropped rather than sent, and that is the
593
+ * one case worth spelling out. The daemon defaults an EMPTY name to
594
+ * "<computer> <timestamp>" and stores anything else exactly as it arrives (see
595
+ * `newSnapMeta` in the platform's server/snapshot.go), so " " is the single
596
+ * input that produces a snapshot nobody can pick out of a list — a blank row
597
+ * where the generated name it displaced would have said something.
598
+ */
599
+ export function snapshotBody(args) {
600
+ const name = args.name?.trim();
601
+ return omitUndefined({ memory: args.memory, name: name || undefined });
602
+ }
603
+ export function scheduleBody(args) {
604
+ const { enabled, hour, minute = 0, tz = 'UTC' } = args;
605
+ if (hour < 0 || hour > 23)
606
+ throw new Error('hour must be 0-23');
607
+ if (minute < 0 || minute > 59)
608
+ throw new Error('minute must be 0-59');
609
+ return { enabled, hour, minute, tz };
610
+ }
611
+ export function agentBody(args) {
612
+ return omitUndefined({ ...args });
613
+ }
614
+ /** One webhook subscription. The id is `whk-`-shaped. */
615
+ export const webhook = (id) => `${WEBHOOKS}/${segment('webhook_id', id)}`;
616
+ /** rotate | test | deliveries */
617
+ export const webhookAction = (id, action) => `${webhook(id)}/${action}`;
618
+ /** The platform's own caps (`DESCRIPTION_MAX`, `COMPUTERS_MAX` in its lib/webhooks). */
619
+ export const WEBHOOK_DESCRIPTION_MAX = 200;
620
+ export const WEBHOOK_COMPUTERS_MAX = 64;
621
+ /**
622
+ * The body for a webhook create or update — the fields, checked before they
623
+ * cost a round trip.
624
+ *
625
+ * Three of the platform's refusals are mirrored here, because each is a call
626
+ * that can only fail: a url that is not `https:` or carries a username or
627
+ * password, a description over the cap, and a `computers` filter over its cap.
628
+ * The one that is NOT mirrored is the address check — that the hostname resolves
629
+ * to a public address — because it is a DNS answer the platform gets at create
630
+ * time, and a guess made here would be wrong in both directions.
631
+ *
632
+ * `events` and `computers` are de-duplicated rather than refused for repeats,
633
+ * the way the platform stores them. The event vocabulary is not checked at all:
634
+ * the platform refuses an unknown type with a 400 that lists the current ones,
635
+ * and a list held here would refuse a type it had started accepting.
636
+ *
637
+ * Undefined fields are dropped. On an update that is what "leave it as it is"
638
+ * means, and on a create it is what lets the platform apply its defaults.
639
+ */
640
+ export function webhookBody(args) {
641
+ let url;
642
+ if (args.url !== undefined) {
643
+ url = args.url.trim();
644
+ let parsed;
645
+ try {
646
+ parsed = new URL(url);
647
+ }
648
+ catch {
649
+ throw new Error(`url is not a URL: ${JSON.stringify(args.url)}`);
650
+ }
651
+ if (parsed.protocol !== 'https:') {
652
+ throw new Error(`url must be https://, and this one is ${parsed.protocol}//`);
653
+ }
654
+ if (parsed.username || parsed.password) {
655
+ throw new Error('url must not carry a username or password');
656
+ }
657
+ }
658
+ if (args.description !== undefined && args.description.length > WEBHOOK_DESCRIPTION_MAX) {
659
+ throw new Error(`description is ${args.description.length} characters; the platform keeps at most ${WEBHOOK_DESCRIPTION_MAX}`);
660
+ }
661
+ const events = args.events === undefined ? undefined : [...new Set(args.events)];
662
+ const computers = args.computers === undefined ? undefined : [...new Set(args.computers)];
663
+ if (computers?.some((c) => !c.trim())) {
664
+ throw new Error('computers must not contain an empty id');
665
+ }
666
+ if (computers && computers.length > WEBHOOK_COMPUTERS_MAX) {
667
+ throw new Error(`computers names ${computers.length} computers; the platform takes at most ${WEBHOOK_COMPUTERS_MAX} — filter at the receiver instead`);
668
+ }
669
+ return omitUndefined({
670
+ url,
671
+ description: args.description,
672
+ events,
673
+ computers,
674
+ enabled: args.enabled,
675
+ });
676
+ }
677
+ //# sourceMappingURL=paths.js.map