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.
- package/LICENSE +21 -0
- package/README.md +544 -0
- package/dist/api.d.ts +186 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +932 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +292 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +560 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +873 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +406 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +1679 -0
- package/dist/events.js.map +1 -0
- package/dist/format.d.ts +125 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +180 -0
- package/dist/format.js.map +1 -0
- package/dist/http.d.ts +46 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +792 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +394 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +677 -0
- package/dist/paths.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +97 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +78 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +166 -0
- package/dist/session.js.map +1 -0
- package/dist/stdio.d.ts +11 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +43 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools/agent.d.ts +16 -0
- package/dist/tools/agent.d.ts.map +1 -0
- package/dist/tools/agent.js +147 -0
- package/dist/tools/agent.js.map +1 -0
- package/dist/tools/computers.d.ts +3 -0
- package/dist/tools/computers.d.ts.map +1 -0
- package/dist/tools/computers.js +1037 -0
- package/dist/tools/computers.js.map +1 -0
- package/dist/tools/events.d.ts +3 -0
- package/dist/tools/events.d.ts.map +1 -0
- package/dist/tools/events.js +1077 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/guest.d.ts +3 -0
- package/dist/tools/guest.d.ts.map +1 -0
- package/dist/tools/guest.js +761 -0
- package/dist/tools/guest.js.map +1 -0
- package/dist/tools/input.d.ts +3 -0
- package/dist/tools/input.d.ts.map +1 -0
- package/dist/tools/input.js +240 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/snapshots.d.ts +3 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +333 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/tools/templates.js +492 -0
- package/dist/tools/templates.js.map +1 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/webhooks.d.ts +3 -0
- package/dist/tools/webhooks.d.ts.map +1 -0
- package/dist/tools/webhooks.js +260 -0
- package/dist/tools/webhooks.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ConflictError, GatewayTimeoutError, platformSaid, RangeNotSatisfiableError, } from '../errors.js';
|
|
3
|
+
import { guarded, image, isInlineImage, json, MAX_INLINE_IMAGE_BYTES, refused, said, text, } from '../format.js';
|
|
4
|
+
import * as P from '../paths.js';
|
|
5
|
+
const idArg = {
|
|
6
|
+
computer_id: z
|
|
7
|
+
.string()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe('Which computer. Defaults to the one selected with use_computer.'),
|
|
10
|
+
};
|
|
11
|
+
const pidSchema = z
|
|
12
|
+
.number()
|
|
13
|
+
.int()
|
|
14
|
+
.positive()
|
|
15
|
+
.max(Number.MAX_SAFE_INTEGER)
|
|
16
|
+
.describe('The positive, safe-integer pid exec returned.');
|
|
17
|
+
/**
|
|
18
|
+
* How much of a file this server will put into a model's context.
|
|
19
|
+
*
|
|
20
|
+
* The platform moves up to 64 MiB in one request, which is right for artifacts
|
|
21
|
+
* and catastrophic for a context window. A read that came back at that size
|
|
22
|
+
* would not be a large answer, it would be the end of the conversation — so the
|
|
23
|
+
* read is bounded here, says how much it kept, and says where to ask for the
|
|
24
|
+
* next piece.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_INLINE_BYTES = 256 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* How large a window a read asks the platform for.
|
|
29
|
+
*
|
|
30
|
+
* The most this tool could ever hand back, which is the image cap rather than
|
|
31
|
+
* the text one: the content type is not known until the response arrives, and a
|
|
32
|
+
* window sized for text would cut every image over 256 KiB into bytes that will
|
|
33
|
+
* not decode. Text is cut to MAX_INLINE_BYTES on arrival, and the rest of the
|
|
34
|
+
* body is cancelled rather than read.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_WINDOW_BYTES = MAX_INLINE_IMAGE_BYTES;
|
|
37
|
+
const absolutePath = (what) => z
|
|
38
|
+
.string()
|
|
39
|
+
.startsWith('/', `${what} must be an absolute path starting with /`)
|
|
40
|
+
.describe(`Absolute path ${what === 'cwd' ? 'to run in' : 'inside the guest'}.`);
|
|
41
|
+
/**
|
|
42
|
+
* The refusal that means a computer's background slots are all held, and the
|
|
43
|
+
* number the platform named — or `undefined` for any other conflict.
|
|
44
|
+
*
|
|
45
|
+
* A computer runs at most sixteen background commands at once (platform
|
|
46
|
+
* OPL-3584). The seventeenth is refused 409 with `this computer already has 16
|
|
47
|
+
* background commands running`, and that refusal deliberately carries NO
|
|
48
|
+
* `reason` (platform OPL-3898): the slots may be held by long-lived servers, so
|
|
49
|
+
* the platform will not advise a retry it cannot promise. An absent word falls
|
|
50
|
+
* back to the type answer, and for a 409 the type is {@link ConflictError},
|
|
51
|
+
* which {@link isTransient} calls worth retrying. So this is the one conflict
|
|
52
|
+
* where that fallback is optimistic in practice — correctly, on the platform's
|
|
53
|
+
* side, and leaving the model a sentence with no next step in it.
|
|
54
|
+
*
|
|
55
|
+
* Matched on PROSE, which is worth saying out loud, because matching on prose is
|
|
56
|
+
* what OPL-3724 got this client out of. What that decision governs is the retry
|
|
57
|
+
* PREDICATES: an exported contract, mirrored word for word by two other clients,
|
|
58
|
+
* where a reworded sentence silently changes what an embedder's program does.
|
|
59
|
+
* Neither predicate is touched here and neither knows this sentence exists. What
|
|
60
|
+
* is decided here is one tool's next-step paragraph — the platform's own message
|
|
61
|
+
* is printed in full either way, and a match that stops matching degrades to
|
|
62
|
+
* exactly the answer this route gives today.
|
|
63
|
+
*
|
|
64
|
+
* Gated as tightly as the evidence allows: a 409 and nothing else, only where
|
|
65
|
+
* this very call asked for a slot, only where the platform classified nothing —
|
|
66
|
+
* if a later version does classify this, its word wins and {@link reasonAdvice}
|
|
67
|
+
* speaks instead of the paragraph below — and only on a sentence carrying both
|
|
68
|
+
* the count and the noun phrase. The count is read back out rather than written
|
|
69
|
+
* in, so raising the cap on the platform cannot turn this into a lie.
|
|
70
|
+
*/
|
|
71
|
+
const BACKGROUND_SLOTS_FULL = /already has (\d+) background commands? running/i;
|
|
72
|
+
function backgroundSlotsFull(err) {
|
|
73
|
+
if (!(err instanceof ConflictError) || err.reason !== undefined)
|
|
74
|
+
return undefined;
|
|
75
|
+
const held = Number(BACKGROUND_SLOTS_FULL.exec(err.message)?.[1]);
|
|
76
|
+
return Number.isSafeInteger(held) && held > 0 ? held : undefined;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* That refusal, turned into a next step — the shape `moveOffered` gives the
|
|
80
|
+
* resize that needs a move (OPL-3775).
|
|
81
|
+
*
|
|
82
|
+
* The platform's sentence first and whole, because it is the one that says how
|
|
83
|
+
* many are running and on which computer. What is added is what that sentence
|
|
84
|
+
* will not say: that nothing on this side frees a slot, and the name of the tool
|
|
85
|
+
* that does. A slot is held for exactly as long as its command runs — a command
|
|
86
|
+
* that has finished is not counted, so there is nothing to reap and no poll that
|
|
87
|
+
* releases anything — and `background` is the flag this server recommends for
|
|
88
|
+
* servers, which do not exit.
|
|
89
|
+
*
|
|
90
|
+
* It stops short of "do not retry", because that would be false and this file
|
|
91
|
+
* has no way to know which it is: a build among the sixteen finishes on its own
|
|
92
|
+
* and its slot comes back moments later. It names the two situations and lets
|
|
93
|
+
* the caller say which one it is in, which is the difference between a next step
|
|
94
|
+
* and a guess.
|
|
95
|
+
*/
|
|
96
|
+
const backgroundFull = (err, held) => refused(`${err.message}\n\nA slot is held for as long as its command runs, and all ${held} are held now. ` +
|
|
97
|
+
`Nothing on this side frees one: a command that has already finished is not counted, so there is ` +
|
|
98
|
+
`nothing to reap, and a poll reads output rather than releasing anything. If any of the ${held} are ` +
|
|
99
|
+
`servers, they do not exit on their own and another exec with background: true gets this same answer ` +
|
|
100
|
+
`for as long as they run. The way out is to stop one you no longer need — exec_kill on a pid an ` +
|
|
101
|
+
`earlier exec returned, with exec_poll to see which are still running. If they are builds or installs ` +
|
|
102
|
+
`rather than servers, one of them finishes by itself and its slot comes back a moment later.`);
|
|
103
|
+
/**
|
|
104
|
+
* A window action that timed out on the way back, turned into the next step it
|
|
105
|
+
* needs — which is a READ, and never this call again (OPL-3910).
|
|
106
|
+
*
|
|
107
|
+
* The platform documents a 504 on `POST /computers/{id}/windows/{window}` and
|
|
108
|
+
* says what it means: the guest accepted the action and did not report the
|
|
109
|
+
* result before the deadline, so the route answers without a `reason` on
|
|
110
|
+
* purpose, because the action may already have happened and an uncertain
|
|
111
|
+
* outcome is not permission to repeat it (platform OPL-3898).
|
|
112
|
+
*
|
|
113
|
+
* Substituted rather than appended, which is the opposite of what
|
|
114
|
+
* {@link backgroundFull} does, and for a reason that only applies here. A 504
|
|
115
|
+
* whose response named nothing gets its message written by
|
|
116
|
+
* `errorForStatus`, and that message ends "the same call again is the
|
|
117
|
+
* move" — true for the reads and creates it was written for, false on a route
|
|
118
|
+
* whose actions are applied once. Printing it above a paragraph that says the
|
|
119
|
+
* reverse would leave the model to pick, so {@link platformSaid} asks whether a
|
|
120
|
+
* hop that actually knew this request said something. Its sentence is kept,
|
|
121
|
+
* but merely naming a timeout does not prove what happened at the dispatch
|
|
122
|
+
* boundary. Only a response that explicitly says the request or action was not
|
|
123
|
+
* dispatched settles the outcome; every other structured gateway timeout, and
|
|
124
|
+
* one with no sentence at all, gets the unknown-outcome advice below.
|
|
125
|
+
*
|
|
126
|
+
* The whole class rather than the one status. A 524 is the same event reached
|
|
127
|
+
* from the proxy's ceiling instead of from the guest's silence, and it carries
|
|
128
|
+
* the stronger version of the same fact — the platform very likely has the
|
|
129
|
+
* request and is still working on it. Either way the outcome is unknown and
|
|
130
|
+
* `list_windows` is what settles it.
|
|
131
|
+
*
|
|
132
|
+
* `close` gets its own sentence because it is the only action here that cannot
|
|
133
|
+
* be undone. The others are untidy on a repeat: a second `move` puts the frame
|
|
134
|
+
* where it already is.
|
|
135
|
+
*/
|
|
136
|
+
const WINDOW_ACTION_NOT_DISPATCHED = /^\s*(?:(?:upstream|gateway|service)(?: was)? unavailable before dispatch|(?:the )?(?:request|(?:window )?action|command) (?:(?:was (?:not|never)|has not been|never) dispatched|did not (?:get dispatched|reach the guest))|nothing (?:was|got) dispatched)[.!]?\s*$/i;
|
|
137
|
+
const windowOutcomeMayBeUnknown = (err) => {
|
|
138
|
+
const named = platformSaid(err.body);
|
|
139
|
+
return named === undefined || !WINDOW_ACTION_NOT_DISPATCHED.test(named);
|
|
140
|
+
};
|
|
141
|
+
const windowOutcomeUnknown = (err, action, windowId) => {
|
|
142
|
+
const named = platformSaid(err.body);
|
|
143
|
+
return refused(`${named ? `${named}\n\n` : ''}The ${action} on ${windowId} did not report a result before the ` +
|
|
144
|
+
`deadline (HTTP ${err.status}). That is not a refusal and it is not a report that nothing ` +
|
|
145
|
+
`happened: the guest may have taken the action and lost the race to say so, so the outcome is ` +
|
|
146
|
+
`UNKNOWN and the ${action} may already have been applied. Do not send this call again to find ` +
|
|
147
|
+
`out — call list_windows, which says what the desktop is actually like now. ` +
|
|
148
|
+
(action === 'close'
|
|
149
|
+
? `A close least of all: there is no undo for a window that was holding unsaved work, and a ` +
|
|
150
|
+
`window id is not reserved forever — the X server can hand the same id to something else — ` +
|
|
151
|
+
`so a second close is not safely a no-op on a window that has already gone.`
|
|
152
|
+
: `A repeated ${action} is untidy rather than destructive, but it still answers with a guess ` +
|
|
153
|
+
`where a read answers with the window.`));
|
|
154
|
+
};
|
|
155
|
+
export const registerGuest = (server, session) => {
|
|
156
|
+
server.registerTool('exec', {
|
|
157
|
+
title: 'Run a command in the guest',
|
|
158
|
+
description: 'Run a shell command inside the computer. Runs as root with no display by default — anything that opens a window needs desktop: true, anything slower than a few seconds needs background: true, and anything that needs an environment variable takes env rather than an assignment written into the command. Against the hosted platform, waiting here is capped at about two minutes by a proxy in front of it, not by timeout_s. One computer runs at most sixteen background commands at once: the seventeenth is refused rather than queued, and exec_kill on a pid you already hold is what makes room.',
|
|
159
|
+
inputSchema: {
|
|
160
|
+
...idArg,
|
|
161
|
+
command: z.string().describe('A shell command line.'),
|
|
162
|
+
timeout_s: z
|
|
163
|
+
.number()
|
|
164
|
+
.int()
|
|
165
|
+
.min(1)
|
|
166
|
+
.max(300)
|
|
167
|
+
.default(30)
|
|
168
|
+
.describe('How long to wait for it to exit. A command that outlives this keeps running inside the guest — your deadline passing means you stopped waiting, not that the work was destroyed. Against the hosted app.mandala.computer, do not reach past about 120 here: a proxy in front of the platform abandons the request at roughly two minutes and answers 524 whatever this says, so a larger number buys no time and only delays the failure. Use background: true for anything slower. The range above 120 is for a self-hosted MANDALA_BASE_URL reached without that proxy.'),
|
|
169
|
+
desktop: z
|
|
170
|
+
.boolean()
|
|
171
|
+
.default(false)
|
|
172
|
+
.describe('Run inside the logged-in desktop session instead of as root with no display. Required for anything with a window: the guest agent has no DISPLAY, so a GUI app started without this cannot draw. Linux only.'),
|
|
173
|
+
background: z
|
|
174
|
+
.boolean()
|
|
175
|
+
.default(false)
|
|
176
|
+
.describe('Return a handle immediately instead of waiting. Use for builds, installs, test suites and servers, then read output with exec_poll. To learn that it finished, wait_for_event with types ["process.exited"] and the pid this returns — the computer reports the exit, so waiting for it costs one call rather than an exec_poll loop. Strictly better than backgrounding with "&", which throws away the exit code and the output. A computer runs at most sixteen of these at once, and past that this is refused with a 409 saying how many are already running rather than queued. A slot is held until its command exits, which for a server is never, so the way out of that refusal is exec_kill on a pid an earlier exec returned — not another attempt.'),
|
|
177
|
+
cwd: absolutePath('cwd').optional(),
|
|
178
|
+
env: z
|
|
179
|
+
.record(z.string(), z.string())
|
|
180
|
+
.optional()
|
|
181
|
+
.describe('Environment for this command, as {NAME: "value"}. Use it instead of writing FOO=bar in front of the command: a prefix assignment is shell syntax, so a value holding a space, a quote, a newline or a $ has to be quoted correctly by you and is silently truncated or re-parsed when it is not, while this reaches the process whole and unquoted. It also keeps a secret out of the command line, which is world-readable in the guest\'s ps and, for a background command, comes back to you inside every exec_poll answer. The variables are added on top of the guest\'s login profile rather than replacing it, so PATH and the rest are still there, and they apply to this command only — including with desktop: true. Names must not be empty or contain "=".'),
|
|
182
|
+
},
|
|
183
|
+
}, ({ computer_id, command, timeout_s, desktop, background, cwd, env }, extra) => guarded(async () => {
|
|
184
|
+
const id = session.resolve(computer_id);
|
|
185
|
+
let res;
|
|
186
|
+
try {
|
|
187
|
+
res = await session.api
|
|
188
|
+
.with(extra.signal)
|
|
189
|
+
.json('POST', P.computerAction(id, 'exec'), {
|
|
190
|
+
body: P.execBody({ command, timeout_s, desktop, background, cwd, env }),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// The one refusal on this route whose next step is a different tool
|
|
195
|
+
// (OPL-3909). Caught here rather than in `failed`, for the reason
|
|
196
|
+
// `moveOffered` is caught in update_computer: the answer names
|
|
197
|
+
// exec_kill, and the error class — shared with every embedder and
|
|
198
|
+
// with two other clients — has no business knowing a tool name.
|
|
199
|
+
//
|
|
200
|
+
// Only when this call asked for a slot. The refusal cannot be reached
|
|
201
|
+
// any other way, so the guard costs nothing and makes a false match on
|
|
202
|
+
// a foreground conflict impossible rather than merely unlikely.
|
|
203
|
+
const held = background ? backgroundSlotsFull(err) : undefined;
|
|
204
|
+
if (held !== undefined)
|
|
205
|
+
return backgroundFull(err, held);
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
if (background) {
|
|
209
|
+
// A pid is the whole product of a background exec: without one there
|
|
210
|
+
// is nothing to poll and nothing to kill. Reported as a success, "pid
|
|
211
|
+
// undefined" sends the model to exec_poll with a handle that cannot
|
|
212
|
+
// exist, and the command goes on running in the guest unattended.
|
|
213
|
+
if (!Number.isSafeInteger(res.pid) || res.pid <= 0) {
|
|
214
|
+
return refused(`The command was accepted but the guest reported no pid that is a usable positive safe integer, so there is no safe handle to poll or kill it with. It may still be running inside the computer — check with exec "ps aux".`, res);
|
|
215
|
+
}
|
|
216
|
+
return said(`Started as pid ${res.pid}. Read its output with exec_poll, stop it with exec_kill.`, res);
|
|
217
|
+
}
|
|
218
|
+
return said(execSummary(res), res);
|
|
219
|
+
}));
|
|
220
|
+
server.registerTool('exec_poll', {
|
|
221
|
+
title: 'Read a background command',
|
|
222
|
+
description: "What a backgrounded command has printed since the last time you asked, and whether it has finished. The output is a cursor, not a buffer: each poll gives you only the new bytes, so two readers on one pid split the output between them rather than each seeing all of it. Finishing is also what releases a computer's background slot, so this is how you tell which of your handles still hold one — the poll itself frees nothing.",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
...idArg,
|
|
225
|
+
pid: pidSchema,
|
|
226
|
+
},
|
|
227
|
+
// Deliberately not readOnlyHint. It read as one — nothing is created and
|
|
228
|
+
// nothing is destroyed — but the annotation is the sentence directly
|
|
229
|
+
// above it, negated: a poll advances a cursor in the guest, so the bytes
|
|
230
|
+
// it returns are bytes no later poll can return. Clients treat the hint
|
|
231
|
+
// as licence to call without asking and to retry a call that timed out,
|
|
232
|
+
// and a retried "read-only" poll silently drops whatever the first
|
|
233
|
+
// attempt had already consumed.
|
|
234
|
+
}, ({ computer_id, pid }, extra) => guarded(async () => {
|
|
235
|
+
const id = session.resolve(computer_id);
|
|
236
|
+
const res = await session.api
|
|
237
|
+
.with(extra.signal)
|
|
238
|
+
.json('GET', P.execHandle(id, pid));
|
|
239
|
+
const more = res.more
|
|
240
|
+
? '\n\n`more` is set — there is further output waiting; poll again straight away.'
|
|
241
|
+
: '';
|
|
242
|
+
return said(`${execSummary(res)}${more}`, res);
|
|
243
|
+
}));
|
|
244
|
+
server.registerTool('exec_kill', {
|
|
245
|
+
title: 'Stop a background command',
|
|
246
|
+
description: 'Kill a backgrounded command and everything it started. Answers with its final state, including whatever it printed that you had not read. Also how you make room when a computer is already running its maximum of sixteen background commands: the slot comes back with the command.',
|
|
247
|
+
inputSchema: { ...idArg, pid: pidSchema },
|
|
248
|
+
annotations: { destructiveHint: true },
|
|
249
|
+
}, ({ computer_id, pid }, extra) => guarded(async () => {
|
|
250
|
+
const id = session.resolve(computer_id);
|
|
251
|
+
// `send`, because a DELETE answering 204 is the ordinary REST shape and
|
|
252
|
+
// `json` now raises on an empty body. Reported as an error, the kill
|
|
253
|
+
// that in fact succeeded would send the model back at a pid that no
|
|
254
|
+
// longer exists.
|
|
255
|
+
const res = await session.api
|
|
256
|
+
.with(extra.signal)
|
|
257
|
+
.send('DELETE', P.execHandle(id, pid));
|
|
258
|
+
return said(`Killed pid ${pid}.`, res);
|
|
259
|
+
}));
|
|
260
|
+
server.registerTool('open_url', {
|
|
261
|
+
title: 'Open a URL on the desktop',
|
|
262
|
+
description: "Put a web page on the screen in the guest's browser. The command returns before the window draws — on a cold browser that gap has been as long as ten seconds — so screenshot until the screen changes rather than concluding from one frame that nothing launched.",
|
|
263
|
+
inputSchema: { ...idArg, url: z.string().url() },
|
|
264
|
+
}, ({ computer_id, url }, extra) => guarded(async () => {
|
|
265
|
+
const id = session.resolve(computer_id);
|
|
266
|
+
const res = await session.api
|
|
267
|
+
.with(extra.signal)
|
|
268
|
+
.json('POST', P.computerAction(id, 'exec'), {
|
|
269
|
+
body: P.execBody({ command: P.openUrlCommand(url), timeout_s: 30, desktop: true }),
|
|
270
|
+
});
|
|
271
|
+
return said(`Asked the desktop to open ${url}. Give it a few seconds, then screenshot — the browser draws after the command returns.`, res);
|
|
272
|
+
}));
|
|
273
|
+
server.registerTool('list_windows', {
|
|
274
|
+
title: 'List what is on the desktop',
|
|
275
|
+
description: 'The windows the window manager knows about — id, title, class, geometry, focus. A screenshot says what the desktop looks like; this says what any of it is, which is how you tell a browser that failed to open from one that has not painted yet. Match on class, not title: the class is the application, the title is whatever page it is showing. Linux only.',
|
|
276
|
+
inputSchema: {
|
|
277
|
+
...idArg,
|
|
278
|
+
include_all: z
|
|
279
|
+
.boolean()
|
|
280
|
+
.default(false)
|
|
281
|
+
.describe('Include panels, the desktop wallpaper and other furniture. Off by default — a stock guest with one terminal open has five windows, four of which are not applications.'),
|
|
282
|
+
},
|
|
283
|
+
annotations: { readOnlyHint: true },
|
|
284
|
+
}, ({ computer_id, include_all }, extra) => guarded(async () => {
|
|
285
|
+
const id = session.resolve(computer_id);
|
|
286
|
+
const res = await session.api
|
|
287
|
+
.with(extra.signal)
|
|
288
|
+
.json('GET', P.computerAction(id, 'windows'), {
|
|
289
|
+
query: { include: include_all ? 'all' : undefined },
|
|
290
|
+
});
|
|
291
|
+
return json(res);
|
|
292
|
+
}));
|
|
293
|
+
server.registerTool('window_action', {
|
|
294
|
+
title: 'Act on a window',
|
|
295
|
+
description: 'Focus, raise, minimize, maximize, unmaximize, close, move or resize one window. The reply is the window afterwards, not an acknowledgement — the window manager places the frame and applications snap to their own grid, so a move to 300,200 routinely lands at 305,229. Believe the response, not the request. Prefer focus over raise: raising without focusing gives a window that is visibly in front and silently not receiving keystrokes. A 504 is neither a refusal nor a report that nothing happened unless its structured response explicitly says the request was not dispatched: an absent or ambiguous explanation leaves the action possibly already applied. An uncertain outcome is not permission to repeat it — the next call is list_windows, which says what the desktop is now, not this one again. Least of all for close, which cannot be undone.',
|
|
296
|
+
inputSchema: {
|
|
297
|
+
...idArg,
|
|
298
|
+
window_id: z.string().describe('From list_windows, e.g. "0x2600003".'),
|
|
299
|
+
action: z.enum(P.WINDOW_ACTIONS),
|
|
300
|
+
x: z.number().int().optional().describe('For move.'),
|
|
301
|
+
y: z.number().int().optional().describe('For move.'),
|
|
302
|
+
width: z.number().int().optional().describe('For resize.'),
|
|
303
|
+
height: z.number().int().optional().describe('For resize.'),
|
|
304
|
+
},
|
|
305
|
+
}, ({ computer_id, window_id, action, x, y, width, height }, extra) => guarded(async () => {
|
|
306
|
+
const id = session.resolve(computer_id);
|
|
307
|
+
let res;
|
|
308
|
+
try {
|
|
309
|
+
res = await session.api.with(extra.signal).json('POST', P.window_(id, window_id), {
|
|
310
|
+
body: P.windowBody({ action, x, y, width, height }),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
// The failure whose advice has to arrive WITH it (OPL-3910). A
|
|
315
|
+
// description is read once, at the top of a session, and the turn
|
|
316
|
+
// that meets a 504 is not that turn — so the one refusal on this
|
|
317
|
+
// route that must not be retried says so where it happens, the way
|
|
318
|
+
// exec's full slot table does above.
|
|
319
|
+
if (err instanceof GatewayTimeoutError && windowOutcomeMayBeUnknown(err)) {
|
|
320
|
+
return windowOutcomeUnknown(err, action, window_id);
|
|
321
|
+
}
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
return said(`${action} on ${window_id}. This is the window as it now is:`, res);
|
|
325
|
+
}));
|
|
326
|
+
server.registerTool('read_clipboard', {
|
|
327
|
+
title: "Read the desktop's clipboard",
|
|
328
|
+
description: "What is on the computer's desktop clipboard right now — the CLIPBOARD selection, which is what Ctrl-C writes and Ctrl-V pastes. Use this rather than running xclip through exec: exec runs a login shell, so anything the guest user's profile prints lands on the same output ahead of your command's, which corrupts a read you are trying to parse. This does not share that stream. It requires a Linux desktop image with xclip installed and is refused on Windows. An older or custom image without xclip gets a 400 that never clears; changing runtime state or retrying cannot fix that image dependency. It is a READ, not a subscription: this call notices nothing on its own, and repeating it is how a copy inside the guest is discovered. What does notice is wait_for_event on clipboard.changed, which fires when the selection changes hands and says which selection it was — the text is deliberately not on that stream, so this is still the call that fetches it. It also does not wake a suspended computer, so a stopped or suspended one is refused rather than started; start_computer first if you need it. The 409s are not alike either, and the answer says which KIND it is rather than leaving you to match on the sentence: a computer that is not running does NOT clear by waiting — only start_computer changes it — while a guest agent that is busy or still inside its boot window is worth asking again in a moment. A desktop session or X server that is not answering carries no such word, deliberately, because the platform cannot tell a guest still coming up from one nobody is logged into: bound your attempts there rather than looping on it. At most 128 KiB comes back, and more than that is refused rather than cut short.",
|
|
329
|
+
inputSchema: { ...idArg },
|
|
330
|
+
annotations: { readOnlyHint: true },
|
|
331
|
+
}, ({ computer_id }, extra) => guarded(async () => {
|
|
332
|
+
const id = session.resolve(computer_id);
|
|
333
|
+
const res = await session.api
|
|
334
|
+
.with(extra.signal)
|
|
335
|
+
.json('GET', P.computerAction(id, 'clipboard'));
|
|
336
|
+
// Checked rather than rendered. `String(undefined)` is "undefined" — a
|
|
337
|
+
// clipboard nobody copied, which a model would go on to paste.
|
|
338
|
+
if (typeof res?.text !== 'string') {
|
|
339
|
+
return refused('The clipboard read came back with no text in it. Nothing was read; try again, and if it keeps happening the computer may not have a desktop session up yet.');
|
|
340
|
+
}
|
|
341
|
+
return res.text === ''
|
|
342
|
+
? said('The desktop clipboard is empty.')
|
|
343
|
+
: said('On the desktop clipboard:', { text: res.text });
|
|
344
|
+
}));
|
|
345
|
+
server.registerTool('write_clipboard', {
|
|
346
|
+
title: "Put text on the desktop's clipboard",
|
|
347
|
+
description: 'Puts text on the computer\u2019s desktop clipboard, ready to paste. This leaves it on the clipboard and touches nothing on screen — follow it with press_key and keys ["ctrl","v"] to get the text into whatever has focus. Those are two separate key NAMES in the array, not one string "ctrl+v", which press_key would reject as an unknown key. Use this rather than the setsid/xclip/base64 recipe through exec: it is one call, it is confirmed, and it cannot be broken by a quote in the text. It requires a Linux desktop image with xclip installed and is refused on Windows. An older or custom image without xclip gets a 400 that never clears; changing runtime state or retrying cannot fix that image dependency. Unlike read_clipboard this DRIVES the computer, so a suspended one is resumed to serve it and that resume is charged. At most 64 KiB of text goes in — half what comes out, because the text crosses to the guest inside a single command argument. The platform confirms the write by reading the selection back before it answers, so a success here means the desktop is holding your text rather than that a command ran; a refusal saying the desktop did not take it means something else claimed the clipboard in that instant, and sending it again works. Not every refusal here is one of those, and the answer says which it is rather than leaving you to match on the sentence: a computer that is not running is a 409 that start_computer fixes and that retrying never will, while a guest agent still inside its boot window is worth another attempt. A desktop that is not answering is deliberately left unclassified — it can equally be a guest still coming up or a session nobody is logged into — so bound your attempts there instead of looping.',
|
|
348
|
+
inputSchema: {
|
|
349
|
+
...idArg,
|
|
350
|
+
text: z.string().describe('The text to put on the clipboard. At most 64 KiB of UTF-8.'),
|
|
351
|
+
},
|
|
352
|
+
}, ({ computer_id, text }, extra) => guarded(async () => {
|
|
353
|
+
const id = session.resolve(computer_id);
|
|
354
|
+
await session.api
|
|
355
|
+
.with(extra.signal)
|
|
356
|
+
.json('PUT', P.computerAction(id, 'clipboard'), { body: P.clipboardBody(text) });
|
|
357
|
+
return said('On the desktop clipboard, and the desktop has taken it. To paste it into whatever has focus, ' +
|
|
358
|
+
'call press_key with keys ["ctrl","v"] — two key NAMES, not the one string "ctrl+v" that ' +
|
|
359
|
+
'press_key refuses as an unknown key.');
|
|
360
|
+
}));
|
|
361
|
+
server.registerTool('write_file', {
|
|
362
|
+
title: 'Put a file into the guest',
|
|
363
|
+
description: 'Write a file inside the computer. Paths must be absolute — the guest agent inherits whatever working directory it was started in, so a relative path resolves somewhere you did not name.',
|
|
364
|
+
inputSchema: {
|
|
365
|
+
...idArg,
|
|
366
|
+
path: absolutePath('path').describe('Absolute path inside the guest, e.g. /home/user/Desktop/notes.txt.'),
|
|
367
|
+
content: z.string().describe('The file contents.'),
|
|
368
|
+
encoding: z
|
|
369
|
+
.enum(['utf8', 'base64'])
|
|
370
|
+
.default('utf8')
|
|
371
|
+
.describe('base64 for anything that is not text.'),
|
|
372
|
+
},
|
|
373
|
+
}, ({ computer_id, path, content, encoding }, extra) => guarded(async () => {
|
|
374
|
+
const id = session.resolve(computer_id);
|
|
375
|
+
// Node's base64 decoder is lenient: it drops characters outside the
|
|
376
|
+
// alphabet and stops early on bad padding, without ever throwing. A
|
|
377
|
+
// truncated or garbled payload would then write a short file and be
|
|
378
|
+
// reported as a success — the file is there, it is wrong, and nothing
|
|
379
|
+
// says so. Checked here so the answer is a refusal instead.
|
|
380
|
+
if (encoding === 'base64' && !isBase64(content)) {
|
|
381
|
+
return refused(`That is not valid base64, and decoding it would have written a corrupt ${path} while reporting success. Nothing was written. Re-encode the content, or send it with encoding: "utf8" if it is text.`);
|
|
382
|
+
}
|
|
383
|
+
// The same failure the base64 check above refuses, reached down the
|
|
384
|
+
// other branch: a write that lands and is wrong. Nothing on the utf8
|
|
385
|
+
// path refused half a character — `Buffer.from(…, 'utf8')` substitutes
|
|
386
|
+
// U+FFFD and says nothing — so the file was written with a replacement
|
|
387
|
+
// character where the caller's text was, and the tool reported success.
|
|
388
|
+
// `clipboardBody` has refused exactly this since it was written, on the
|
|
389
|
+
// grounds that a write that succeeds with different text than was asked
|
|
390
|
+
// for is worse than one that fails. A file is not a weaker case than a
|
|
391
|
+
// clipboard; it is the more durable one.
|
|
392
|
+
if (encoding === 'utf8' && P.hasUnpairedSurrogate(content)) {
|
|
393
|
+
return refused(`That content has an unpaired surrogate in it — half of a character, usually from a string cut through the middle of an emoji. It is not valid UTF-8, so ${path} would have been written with a replacement character where your text was, and reported as a success. Nothing was written. Send the whole character, cut the text on a character boundary, or send the exact bytes with encoding: "base64".`);
|
|
394
|
+
}
|
|
395
|
+
const bytes = new Uint8Array(Buffer.from(content, encoding === 'base64' ? 'base64' : 'utf8'));
|
|
396
|
+
const res = await session.api.with(extra.signal).json('PUT', P.computerAction(id, 'files'),
|
|
397
|
+
// The path is a query parameter, and the URL builder encodes it. Doing
|
|
398
|
+
// that matters more than it looks: `+` decodes to a space and `&`
|
|
399
|
+
// ends the parameter, so an unencoded path with punctuation in it
|
|
400
|
+
// writes a DIFFERENT file and nothing reports that, because the
|
|
401
|
+
// platform never sees what was meant.
|
|
402
|
+
{ query: { path }, raw: bytes });
|
|
403
|
+
return said(`Wrote ${res.bytes ?? bytes.length} bytes to ${res.path ?? path}.`, res);
|
|
404
|
+
}));
|
|
405
|
+
server.registerTool('read_file', {
|
|
406
|
+
title: 'Get a file out of the guest',
|
|
407
|
+
description: 'Read a file from inside the computer. Text comes back as text and images come back as images; anything else comes back base64. A large file comes back a window at a time rather than filling the conversation: `offset` says where the window starts, and the note under a truncated read gives the exact offset to pass next. There is no size a file can be that makes it unreadable this way — a 2 GB log is pages, not a refusal — but a file you want whole is still better pushed out of the guest than carried through a conversation, and the note says how.',
|
|
408
|
+
inputSchema: {
|
|
409
|
+
...idArg,
|
|
410
|
+
path: absolutePath('path'),
|
|
411
|
+
offset: z
|
|
412
|
+
.number()
|
|
413
|
+
.int()
|
|
414
|
+
.min(0)
|
|
415
|
+
.max(Number.MAX_SAFE_INTEGER)
|
|
416
|
+
.default(0)
|
|
417
|
+
.describe('Byte offset to start at. 0 is the beginning of the file. Each call returns a window from here, and the truncation note names the offset of the byte after the last one it returned — pass that to read on. Never assume a window covers what you asked for: read the offset out of the note rather than adding a fixed number.'),
|
|
418
|
+
},
|
|
419
|
+
annotations: { readOnlyHint: true },
|
|
420
|
+
}, ({ computer_id, path, offset }, extra) => guarded(async () => {
|
|
421
|
+
const id = session.resolve(computer_id);
|
|
422
|
+
let file;
|
|
423
|
+
try {
|
|
424
|
+
file = await session.api.with(extra.signal).bytes('GET', P.computerAction(id, 'files'), {
|
|
425
|
+
query: { path },
|
|
426
|
+
// The window this tool asks the platform for, which is not the
|
|
427
|
+
// same as the window it will return. It is the larger of the two
|
|
428
|
+
// caps below — the most this tool could ever hand back — because
|
|
429
|
+
// the content type that decides between them is not known until
|
|
430
|
+
// the response arrives, and asking for the text window would clip
|
|
431
|
+
// every raster image over 256 KiB into something that will not
|
|
432
|
+
// decode. SVG and other non-raster `image/*` types are not
|
|
433
|
+
// inlined as pictures, so they take the text cap — otherwise an
|
|
434
|
+
// 8 MiB SVG would land in the conversation as text/base64.
|
|
435
|
+
//
|
|
436
|
+
// Asking for a window at all is what makes a large file reachable:
|
|
437
|
+
// without a Range the platform serves whole files and refuses
|
|
438
|
+
// anything past 64 MiB outright, so this tool's answer for a 2 GB
|
|
439
|
+
// log used to be a 413 and a suggestion to go and use exec.
|
|
440
|
+
headers: { Range: `bytes=${offset}-${windowEnd(offset)}` },
|
|
441
|
+
}, (contentType) => isInlineImage(contentType) ? MAX_INLINE_IMAGE_BYTES : MAX_INLINE_BYTES);
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
if (err instanceof RangeNotSatisfiableError)
|
|
445
|
+
return pastEnd(path, offset, err.size);
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
const served = file.window;
|
|
449
|
+
// A 206 promises a particular window, and only the requested start can
|
|
450
|
+
// safely be used as this page. Trusting a contradictory Content-Range
|
|
451
|
+
// can skip the gap before a later window or repeat a stale earlier one
|
|
452
|
+
// forever. A 200 is different: an unmeasurable file may legitimately
|
|
453
|
+
// ignore Range, and that case is diagnosed below without calling it a
|
|
454
|
+
// served window.
|
|
455
|
+
if (served && served.start !== offset) {
|
|
456
|
+
return refused(`${path} was requested from offset ${offset}, but the platform served a window starting at offset ${served.start}. Refusing the mismatched 206 response because using it could skip or repeat file bytes; retry the read rather than continuing from this response.`);
|
|
457
|
+
}
|
|
458
|
+
const start = served?.start ?? 0;
|
|
459
|
+
const received = file.bytes;
|
|
460
|
+
const receivedNext = start + received.length;
|
|
461
|
+
const total = file.totalBytes;
|
|
462
|
+
const size = total === undefined
|
|
463
|
+
? served
|
|
464
|
+
? 'an unknown number of'
|
|
465
|
+
: `more than ${receivedNext}`
|
|
466
|
+
: String(total);
|
|
467
|
+
// `file.truncated` is about this response body; `more` is about the
|
|
468
|
+
// file. They stopped being the same question when the Range arrived: a
|
|
469
|
+
// 9 MiB image comes back as a complete 8 MiB window that was never
|
|
470
|
+
// truncated, and so does a window the platform trimmed. On a 206 the
|
|
471
|
+
// end of the file comes from the Content-Range and nowhere else.
|
|
472
|
+
const more = total === undefined ? served !== undefined || file.truncated : receivedNext < total;
|
|
473
|
+
// Everything, rather than a part of it. Kept apart from `more` because
|
|
474
|
+
// a read that began at an offset holds only a part of the file even
|
|
475
|
+
// when it read that part to the end — which is what the image branch
|
|
476
|
+
// has to refuse and what the header line has to say.
|
|
477
|
+
const whole = start === 0 && !more;
|
|
478
|
+
// The platform did not serve a window: either it said outright that it
|
|
479
|
+
// cannot (`Accept-Ranges: none`, a file whose length the guest cannot
|
|
480
|
+
// measure) or a hop dropped the status on the way. Either way these
|
|
481
|
+
// bytes are the head of the file and an offset means nothing here.
|
|
482
|
+
const unranged = !file.window;
|
|
483
|
+
// And the caller asked for one, so what came back is not what was asked
|
|
484
|
+
// for. This is the half that has to be said even when nothing was
|
|
485
|
+
// truncated: a short /proc file read at offset 5000 arrives whole and
|
|
486
|
+
// reads as a clean answer, and it is a different stretch of bytes from
|
|
487
|
+
// the one that was requested.
|
|
488
|
+
const misled = unranged && offset > 0;
|
|
489
|
+
if (isInlineImage(file.contentType)) {
|
|
490
|
+
// A window of an image is not an image, so this stays a refusal. Two
|
|
491
|
+
// ways to be holding one, and they are different mistakes: a picture
|
|
492
|
+
// too big to put in a conversation, and a picture somebody asked for
|
|
493
|
+
// the middle of. Only the first is about the cap, and telling a caller
|
|
494
|
+
// who passed an offset that their 40 KB icon is over an 8 MiB limit
|
|
495
|
+
// would be a wrong answer wearing a real number.
|
|
496
|
+
if (start > 0) {
|
|
497
|
+
return refused(`${path} is a ${file.contentType} and this read started at offset ${start}, so what came back is a slice out of the middle of it. A slice of a PNG is not a picture, and nothing was decoded as one. Read it with offset: 0 to get the image itself — the file is ${size} bytes, and anything over ${MAX_INLINE_IMAGE_BYTES} is refused there too, with what to do about it.`);
|
|
498
|
+
}
|
|
499
|
+
// A wildcard total says only that this is a window, never that the
|
|
500
|
+
// window is the whole image. Even a short body that fitted under the
|
|
501
|
+
// cap is therefore not safe to decode, and its unknown size is not
|
|
502
|
+
// evidence that it exceeded the cap either.
|
|
503
|
+
if (served && total === undefined) {
|
|
504
|
+
return refused(`${path} arrived as a partial ${file.contentType} response whose total size is unknown. A window of an image is not a picture, so nothing was decoded as one. Read it again from offset: 0 through an endpoint that reports the full size, or push the original out of the guest with exec "curl -T ${shellQuote(path)} <your-upload-url>".`);
|
|
505
|
+
}
|
|
506
|
+
// The refusal now knows the file's real length, off the window's
|
|
507
|
+
// Content-Range, where it could only say "more than" before — and it
|
|
508
|
+
// no longer reads as though the bytes themselves were out of reach.
|
|
509
|
+
if (more) {
|
|
510
|
+
return refused(`${path} is a ${file.contentType} of ${size} bytes, over the ${MAX_INLINE_IMAGE_BYTES}-byte inline limit. It was not read into the conversation, because an image cannot be truncated and one this size would end it. The file is not out of reach — read_file serves a window of any file at any offset — but a window of a PNG is not a picture, so shrink it in the guest and read that: exec "convert ${shellQuote(path)} -resize 1280x ${shellQuote(`${path}.small.png`)}". To keep the original, push it out of the guest with exec "curl -T ${shellQuote(path)} <your-upload-url>".`);
|
|
511
|
+
}
|
|
512
|
+
if (received.length === 0) {
|
|
513
|
+
return refused(`${path} came back as an empty ${file.contentType} file. Nothing was returned as an image because zero bytes cannot be decoded as one.`);
|
|
514
|
+
}
|
|
515
|
+
// A picture that arrived whole because the offset was ignored is
|
|
516
|
+
// still a picture, so it goes back — but unremarked it reads as the
|
|
517
|
+
// window that was asked for, and the next offset would be ignored
|
|
518
|
+
// just the same. The caption is the only place that can say so.
|
|
519
|
+
const caption = misled
|
|
520
|
+
? `${path} (${size} bytes) — the offset was ignored: this file cannot be read from one, so these are its first bytes and not the ${offset} you asked from.`
|
|
521
|
+
: `${path} (${size} bytes)`;
|
|
522
|
+
return image(received, file.contentType, caption);
|
|
523
|
+
}
|
|
524
|
+
// Do not advance into the middle of a UTF-8 character. If the cap cut
|
|
525
|
+
// a valid text prefix after the leading bytes of its final character,
|
|
526
|
+
// leave those few bytes for the next page. Binary content is preserved
|
|
527
|
+
// exactly: utf8Page only trims when everything before that incomplete
|
|
528
|
+
// tail is itself valid UTF-8.
|
|
529
|
+
const kept = more ? utf8Page(received) : received;
|
|
530
|
+
const next = start + kept.length;
|
|
531
|
+
const note = unranged && (more || misled)
|
|
532
|
+
? `\n\n[${ignoredOffset(path, file, offset, next, total, more)}]`
|
|
533
|
+
: more
|
|
534
|
+
? `\n\n[${continuation(path, start, next, total)}]`
|
|
535
|
+
: '';
|
|
536
|
+
const where = whole
|
|
537
|
+
? `${size} bytes`
|
|
538
|
+
: kept.length === 0
|
|
539
|
+
? `empty window at offset ${start} of ${size}`
|
|
540
|
+
: `bytes ${start}-${next - 1} of ${size}`;
|
|
541
|
+
const decoded = decodeUtf8(kept);
|
|
542
|
+
if (decoded === undefined) {
|
|
543
|
+
return text(`${path} is not text (${where}, ${file.contentType}). Base64:\n\n` +
|
|
544
|
+
Buffer.from(kept).toString('base64') +
|
|
545
|
+
note);
|
|
546
|
+
}
|
|
547
|
+
return text(`${path} (${where}):\n\n${decoded}${note}`);
|
|
548
|
+
}));
|
|
549
|
+
};
|
|
550
|
+
/**
|
|
551
|
+
* The last byte of the window a read asks for, saturated rather than wrapped.
|
|
552
|
+
*
|
|
553
|
+
* `offset` is bounded only by the largest integer JavaScript counts exactly, so
|
|
554
|
+
* adding the window to it can leave that range — and a last-byte-pos that has
|
|
555
|
+
* gone imprecise names a byte nobody meant. Clamping keeps the header a
|
|
556
|
+
* well-formed range whose end is at worst past the end of the file, which the
|
|
557
|
+
* platform trims to the file rather than refusing.
|
|
558
|
+
*/
|
|
559
|
+
function windowEnd(offset) {
|
|
560
|
+
return Math.min(offset + MAX_WINDOW_BYTES - 1, Number.MAX_SAFE_INTEGER);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* What to say when the platform answers 416: the offset named no byte.
|
|
564
|
+
*
|
|
565
|
+
* The mistake a model paging an unmeasured file will actually make, and the one
|
|
566
|
+
* refusal on this route that carries its own fix — the response's Content-Range
|
|
567
|
+
* gives the file's real length, so the answer can name the offset that would
|
|
568
|
+
* have worked instead of leaving another guess to be made.
|
|
569
|
+
*
|
|
570
|
+
* An empty file is the odd case underneath it. A Range against zero bytes is
|
|
571
|
+
* unsatisfiable by the letter of RFC 9110, and the platform says so, but
|
|
572
|
+
* `read_file /tmp/empty` asking for the beginning of a file that has no
|
|
573
|
+
* beginning is not a mistake anybody made — it is a real read of a real file
|
|
574
|
+
* that happens to have nothing in it, and it was a plain answer before this
|
|
575
|
+
* tool started sending a Range. It stays one.
|
|
576
|
+
*/
|
|
577
|
+
function pastEnd(path, offset, size) {
|
|
578
|
+
if (size === 0) {
|
|
579
|
+
return offset === 0
|
|
580
|
+
? text(`${path} (0 bytes): the file is empty.`)
|
|
581
|
+
: refused(`${path} is empty, so offset ${offset} names nothing in it.`);
|
|
582
|
+
}
|
|
583
|
+
if (size === undefined) {
|
|
584
|
+
return refused(`${path} has no byte at offset ${offset}, and the platform did not say how long it is. Read from offset 0 to find out how far it goes.`);
|
|
585
|
+
}
|
|
586
|
+
return refused(`offset ${offset} is past the end of ${path}, which is ${size} bytes. Its last byte is at offset ${size - 1}; read_file with offset: 0 starts again from the beginning.`);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* How much of what came back, said the same way by both notes below.
|
|
590
|
+
*/
|
|
591
|
+
function shown(next, start, total) {
|
|
592
|
+
const of = total === undefined ? 'an unknown number of' : String(total);
|
|
593
|
+
return `showed ${next - start} of ${of} bytes${start ? `, starting at offset ${start}` : ''}`;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* The note under a read that did not reach the end of the file.
|
|
597
|
+
*
|
|
598
|
+
* It names this tool as the way past this tool, which is the whole of what
|
|
599
|
+
* changed here: the note used to say `read_file has no offset and always starts
|
|
600
|
+
* at the beginning`, and sent the reader to `exec "tail -c +N | head -c M"` —
|
|
601
|
+
* a shell in the guest, an agent-side 16 MiB ceiling, and an off-by-one on
|
|
602
|
+
* `tail`'s one-based count, all to read the next 256 KiB of a file.
|
|
603
|
+
*/
|
|
604
|
+
function continuation(path, start, next, total) {
|
|
605
|
+
const pieces = total === undefined ? 'many' : String(Math.ceil((total - next) / MAX_INLINE_BYTES));
|
|
606
|
+
return (`truncated: ${shown(next, start, total)}. To read on, call read_file again with ` +
|
|
607
|
+
`offset: ${next} — that is where this window stopped, and a window is allowed to be ` +
|
|
608
|
+
`shorter than the one asked for, so it is not always where you would have counted to. ` +
|
|
609
|
+
`Covering the rest would take about ${pieces} more reads; if you want the whole file rather ` +
|
|
610
|
+
`than a part of it, push it out of the guest instead of through this conversation, ` +
|
|
611
|
+
`e.g. exec "curl -T ${shellQuote(path)} <your-upload-url>".`);
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* The note under a read whose Range the platform did not serve.
|
|
615
|
+
*
|
|
616
|
+
* A file whose length the guest cannot measure — a `/proc` entry — has no byte
|
|
617
|
+
* positions to name, so the platform ignores the header and sends the file from
|
|
618
|
+
* the start with a `200`. Two things go wrong if that is left unsaid, and they
|
|
619
|
+
* are different enough to need separate sentences: bytes the caller asked for at
|
|
620
|
+
* an offset are not the bytes it got, and there is no offset that would have
|
|
621
|
+
* worked, so an answer that reads like an ordinary truncation sends a paging
|
|
622
|
+
* loop round to ask for the same bytes forever.
|
|
623
|
+
*
|
|
624
|
+
* This is the one place the exec workaround survives, and it earns its keep
|
|
625
|
+
* here: `tail -c +N` is the only thing that can start part-way into a file this
|
|
626
|
+
* route will only ever hand over whole.
|
|
627
|
+
*/
|
|
628
|
+
function ignoredOffset(path, file, offset, next, total, more) {
|
|
629
|
+
const head = more
|
|
630
|
+
? `truncated: ${shown(next, 0, total)}`
|
|
631
|
+
: `read whole: ${next} bytes, which is all of ${path}`;
|
|
632
|
+
const why = file.unrangeable
|
|
633
|
+
? `${path} has no length the guest can report — a /proc entry, say — so the platform served it from the start and ignored the offset`
|
|
634
|
+
: `the platform answered with the whole file rather than a window, so it ignored the offset`;
|
|
635
|
+
const what = offset > 0
|
|
636
|
+
? `these bytes are the START of ${path}, not the ${offset} you asked from`
|
|
637
|
+
: 'an offset would be ignored the same way, so this file cannot be paged';
|
|
638
|
+
const onward = more
|
|
639
|
+
? `Read on with exec "tail -c +${next + 1} ${shellQuote(path)} | head -c ${MAX_INLINE_BYTES}" instead — tail counts from one, which is why that number is one past the last byte shown.`
|
|
640
|
+
: `Nothing is missing from this answer. To read part of a file like this rather than all of it, use exec "tail -c +N ${shellQuote(path)} | head -c M" — tail counts from one, so N is your offset plus one.`;
|
|
641
|
+
return `${head}. ${why} — ${what}. ${onward}`;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Whether a string is base64 the decoder will not silently repair.
|
|
645
|
+
*
|
|
646
|
+
* Drawn to match what Node actually decodes correctly, not to a stricter idea
|
|
647
|
+
* of the format: padding is optional, and the base64url alphabet decodes to the
|
|
648
|
+
* same bytes as the standard one, so refusing either would reject content that
|
|
649
|
+
* used to be written byte-perfectly — with a message claiming it was corrupt.
|
|
650
|
+
* Whitespace is tolerated too; models wrap long payloads, and a newline every
|
|
651
|
+
* 76 characters is what most encoders emit.
|
|
652
|
+
*
|
|
653
|
+
* What is left is the part that genuinely cannot be decoded: a character
|
|
654
|
+
* outside both alphabets, or a length of 4n+1, which is not a whole number of
|
|
655
|
+
* bytes in any padding convention.
|
|
656
|
+
*/
|
|
657
|
+
function isBase64(s) {
|
|
658
|
+
const compact = s.replace(/\s+/g, '');
|
|
659
|
+
if (!compact)
|
|
660
|
+
return true;
|
|
661
|
+
if (!/^[A-Za-z0-9+/\-_]*={0,2}$/.test(compact))
|
|
662
|
+
return false;
|
|
663
|
+
return compact.replace(/=+$/, '').length % 4 !== 1;
|
|
664
|
+
}
|
|
665
|
+
/** Quote one guest path as a single POSIX-shell argument. */
|
|
666
|
+
function shellQuote(value) {
|
|
667
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
668
|
+
}
|
|
669
|
+
/** How many bytes a UTF-8 lead promises, or 1 if it is not a multi-byte lead. */
|
|
670
|
+
function utf8Expected(first) {
|
|
671
|
+
return first >= 0xc2 && first <= 0xdf
|
|
672
|
+
? 2
|
|
673
|
+
: first >= 0xe0 && first <= 0xef
|
|
674
|
+
? 3
|
|
675
|
+
: first >= 0xf0 && first <= 0xf4
|
|
676
|
+
? 4
|
|
677
|
+
: 1;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Start of a genuinely incomplete multi-byte sequence at the tail, if any.
|
|
681
|
+
*
|
|
682
|
+
* Walks back over continuation bytes to the lead. Accepts only a valid lead
|
|
683
|
+
* whose remaining bytes are all continuations and whose length is short of
|
|
684
|
+
* what that lead promised — not "any 1–3 octets that make the prefix decode".
|
|
685
|
+
*/
|
|
686
|
+
function incompleteUtf8Lead(bytes) {
|
|
687
|
+
if (bytes.length === 0)
|
|
688
|
+
return undefined;
|
|
689
|
+
let lead = bytes.length - 1;
|
|
690
|
+
while (lead > 0 && bytes[lead] >= 0x80 && bytes[lead] <= 0xbf)
|
|
691
|
+
lead--;
|
|
692
|
+
const expected = utf8Expected(bytes[lead]);
|
|
693
|
+
const present = bytes.length - lead;
|
|
694
|
+
if (expected <= 1 || present >= expected)
|
|
695
|
+
return undefined;
|
|
696
|
+
return lead;
|
|
697
|
+
}
|
|
698
|
+
/** UTF-8 if it is UTF-8, and undefined if it plainly is not. */
|
|
699
|
+
function decodeUtf8(bytes) {
|
|
700
|
+
// A NUL is legal UTF-8 and is never in a file anybody meant to read as text.
|
|
701
|
+
if (bytes.includes(0))
|
|
702
|
+
return undefined;
|
|
703
|
+
const fatal = new TextDecoder('utf-8', { fatal: true });
|
|
704
|
+
try {
|
|
705
|
+
return fatal.decode(bytes);
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
// A truncated read can cut a multi-byte character in half. One incomplete
|
|
709
|
+
// sequence at the very end is a casualty of the cap rather than proof the
|
|
710
|
+
// file is binary. A real U+FFFD (EF BF BD) is valid UTF-8 and decodes
|
|
711
|
+
// fatally above. Stray 0xff/0xfe (or any other invalid suffix) must not
|
|
712
|
+
// be stripped until the prefix happens to decode.
|
|
713
|
+
const lead = incompleteUtf8Lead(bytes);
|
|
714
|
+
if (lead === undefined)
|
|
715
|
+
return undefined;
|
|
716
|
+
try {
|
|
717
|
+
return `${fatal.decode(bytes.subarray(0, lead))}\ufffd`;
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/** Leave an incomplete final UTF-8 character for the next byte window. */
|
|
725
|
+
function utf8Page(bytes) {
|
|
726
|
+
if (bytes.length === 0)
|
|
727
|
+
return bytes;
|
|
728
|
+
const lead = incompleteUtf8Lead(bytes);
|
|
729
|
+
// Never return an empty page with the same continuation offset. This can
|
|
730
|
+
// only happen for an unusually tiny partial response, not at the normal cap.
|
|
731
|
+
if (lead === undefined || lead === 0)
|
|
732
|
+
return bytes;
|
|
733
|
+
try {
|
|
734
|
+
new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, lead));
|
|
735
|
+
return bytes.subarray(0, lead);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
return bytes;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/** The one line a model needs off an exec result, before the JSON. */
|
|
742
|
+
function execSummary(res) {
|
|
743
|
+
const bits = [];
|
|
744
|
+
if (res.running)
|
|
745
|
+
bits.push('still running');
|
|
746
|
+
// `!= null` and not `!== undefined`: null is the natural JSON encoding of "no
|
|
747
|
+
// exit code yet" for a command that was killed or timed out, and it printed
|
|
748
|
+
// straight through as the line "exit null".
|
|
749
|
+
else if (res.exit_code != null)
|
|
750
|
+
bits.push(`exit ${res.exit_code}`);
|
|
751
|
+
if (res.timed_out) {
|
|
752
|
+
bits.push('TIMED OUT — the command is still running inside the guest; nothing killed it. Re-run with background: true if you need its output');
|
|
753
|
+
}
|
|
754
|
+
if (res.out_truncated) {
|
|
755
|
+
bits.push("OUTPUT TRUNCATED at the guest agent's 16 MiB cap — the exit code is not the signal here, the flag is");
|
|
756
|
+
}
|
|
757
|
+
if (res.killed)
|
|
758
|
+
bits.push('killed');
|
|
759
|
+
return bits.length ? bits.join('; ') : 'done';
|
|
760
|
+
}
|
|
761
|
+
//# sourceMappingURL=guest.js.map
|