mandala-computer-mcp 0.6.0 → 0.8.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/README.md +268 -27
- package/dist/api.d.ts +26 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +23 -0
- package/dist/api.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +57 -1
- package/dist/cli.js.map +1 -1
- package/dist/errors.d.ts +114 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +253 -3
- package/dist/errors.js.map +1 -1
- package/dist/format.d.ts +73 -2
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +81 -3
- package/dist/format.js.map +1 -1
- package/dist/http.d.ts +32 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +440 -57
- package/dist/http.js.map +1 -1
- package/dist/paths.d.ts +75 -7
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +88 -12
- package/dist/paths.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/tool-filters.d.ts +4 -4
- package/dist/tool-filters.d.ts.map +1 -1
- package/dist/tool-filters.js +13 -1
- package/dist/tool-filters.js.map +1 -1
- package/dist/tools/account.d.ts +14 -0
- package/dist/tools/account.d.ts.map +1 -1
- package/dist/tools/account.js +118 -0
- package/dist/tools/account.js.map +1 -1
- package/dist/tools/agent.d.ts.map +1 -1
- package/dist/tools/agent.js +19 -25
- package/dist/tools/agent.js.map +1 -1
- package/dist/tools/chat.d.ts.map +1 -1
- package/dist/tools/chat.js +46 -4
- package/dist/tools/chat.js.map +1 -1
- package/dist/tools/computers.d.ts.map +1 -1
- package/dist/tools/computers.js +297 -51
- package/dist/tools/computers.js.map +1 -1
- package/dist/tools/guest.d.ts.map +1 -1
- package/dist/tools/guest.js +27 -4
- package/dist/tools/guest.js.map +1 -1
- package/dist/tools/input.d.ts +9 -0
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +247 -16
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/operations.d.ts +49 -0
- package/dist/tools/operations.d.ts.map +1 -0
- package/dist/tools/operations.js +255 -0
- package/dist/tools/operations.js.map +1 -0
- package/dist/tools/secrets.d.ts +10 -1
- package/dist/tools/secrets.d.ts.map +1 -1
- package/dist/tools/secrets.js +63 -18
- package/dist/tools/secrets.js.map +1 -1
- package/dist/tools/snapshots.d.ts.map +1 -1
- package/dist/tools/snapshots.js +54 -18
- package/dist/tools/snapshots.js.map +1 -1
- package/dist/tools/ssh.d.ts.map +1 -1
- package/dist/tools/ssh.js +19 -2
- package/dist/tools/ssh.js.map +1 -1
- package/dist/tools/templates.js +2 -2
- package/dist/tools/templates.js.map +1 -1
- package/dist/tools/webhooks.js +1 -1
- package/dist/tools/webhooks.js.map +1 -1
- package/package.json +1 -1
package/dist/tools/computers.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { idempotencyHeaders, idempotencyKeyFor } from '../api.js';
|
|
2
3
|
import { CancelledError, ConflictError, isTransientForPoll, MoveRequiredError, NotFoundError, } from '../errors.js';
|
|
3
|
-
import { describe, guarded, incompleteWarning, json, nothingAdmitted, refused, said, unwrapComputer, withErrorMetadata, withoutCredentials, } from '../format.js';
|
|
4
|
+
import { describe, guarded, incompleteWarning, json, nothingAdmitted, operationClause, operationIdOf, refused, said, secretsOnTheirWay, unwrapComputer, withErrorMetadata, withoutCredentials, } from '../format.js';
|
|
4
5
|
import * as P from '../paths.js';
|
|
5
6
|
import { heartbeat, POLL_MS, pollDelay, sleep } from '../poll.js';
|
|
7
|
+
import { buildIdempotencyKeyArg, idempotencyKeyArg, keyedFailure, restartIdempotencyKeyArg, } from './operations.js';
|
|
6
8
|
import { FILES_DIR, secretBindingsSchema } from './secrets.js';
|
|
7
9
|
const idArg = {
|
|
8
10
|
computer_id: z
|
|
@@ -10,6 +12,52 @@ const idArg = {
|
|
|
10
12
|
.optional()
|
|
11
13
|
.describe('Which computer. Defaults to the one selected with use_computer.'),
|
|
12
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* A browser proxy as the tools take it: checked for shape only. Which schemes,
|
|
17
|
+
* hosts and bypass entries are accepted is the platform's rule and is growing,
|
|
18
|
+
* so a value it refuses comes back as its own 400 sentence rather than a copy
|
|
19
|
+
* of the rule here that would refuse what it has since learned to accept.
|
|
20
|
+
* Strict, as a secret binding is: a misspelt `bypass` dropped would send the
|
|
21
|
+
* browsers through the proxy for every host the caller meant to exempt. The
|
|
22
|
+
* credentials id is checked here because its form is an id's, not a rule that
|
|
23
|
+
* grows, and because a setting replaced whole without it loses the credentials.
|
|
24
|
+
*/
|
|
25
|
+
const browserProxySchema = z.strictObject({
|
|
26
|
+
server: z
|
|
27
|
+
.string()
|
|
28
|
+
.refine((v) => v.trim().length > 0, 'server must not be blank')
|
|
29
|
+
.describe('The proxy URL, e.g. "http://proxy.example.com:3128" or "socks5://127.0.0.1:1080". The platform says which schemes and hosts it accepts, and refuses anything else with a sentence naming why.'),
|
|
30
|
+
bypass: z
|
|
31
|
+
.array(z.string().refine((v) => v.trim().length > 0, 'a bypass entry must not be blank'))
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('Hosts the browsers reach directly: "example.com", "*.example.com", an address or a range such as "192.0.2.0/24", or "<local>" for names with no dot.'),
|
|
34
|
+
credentials_secret_id: z
|
|
35
|
+
.string()
|
|
36
|
+
.regex(/^csec-[0-9a-f]{16}$/, 'credentials_secret_id must be a secret id: csec- and 16 hex')
|
|
37
|
+
.nullable()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('For a proxy that asks for a username and password: the id (csec-…, from list_secrets) of a secret whose value is "user:password". The secret must be bound to the computer as a FILE ({secret_id, file} in its secrets, at create or with set_computer_secrets), or the platform refuses the setting; a computer\'s first secrets are bound while it is stopped, and while the proxy names the secret a rebind that drops that binding is refused. Only with an http:// proxy for now. The secret\'s user:password is sent to the proxy named in server, on every request the browsers make. Leaving it out (or null) means no credentials.'),
|
|
40
|
+
});
|
|
41
|
+
const BROWSER_PROXY_ABOUT = "Sends the computer's browsers (Chromium, Chrome, Firefox) through a proxy; nothing else on it uses the proxy, so exec and a terminal go out directly. Linux only.";
|
|
42
|
+
/**
|
|
43
|
+
* An egress proxy as the tools take it, checked for shape only, as the browser
|
|
44
|
+
* proxy is. Strict: `bypass` copied over from a browser proxy has no meaning
|
|
45
|
+
* here, and a proxy that silently covered the hosts a caller meant to leave out
|
|
46
|
+
* is worse than a refusal naming the key.
|
|
47
|
+
*/
|
|
48
|
+
const egressProxySchema = z.strictObject({
|
|
49
|
+
server: z
|
|
50
|
+
.string()
|
|
51
|
+
.refine((v) => v.trim().length > 0, 'server must not be blank')
|
|
52
|
+
.describe('The proxy URL with an explicit port: "http://host:port" (a proxy that takes CONNECT), "https://host:port" (the same, spoken to over TLS) or "socks5://host:port". Never a username or password in it — name a secret in credentials_secret_id instead. The platform says which hosts it accepts, and refuses anything else with a sentence naming why.'),
|
|
53
|
+
credentials_secret_id: z
|
|
54
|
+
.string()
|
|
55
|
+
.regex(/^csec-[0-9a-f]{16}$/, 'credentials_secret_id must be a secret id: csec- and 16 hex')
|
|
56
|
+
.nullable()
|
|
57
|
+
.optional()
|
|
58
|
+
.describe('For a proxy that asks for a username and password: the id (csec-…, from list_secrets) of a secret whose value is "user:password". It is NOT bound to the computer and the computer never receives it — do not add it to the computer\'s secrets: the computer\'s host holds the value and signs in to the proxy for it. With http:// and socks5:// the credentials cross the network in clear text, so prefer https:// when naming them. Leaving it out (or null) means no credentials.'),
|
|
59
|
+
});
|
|
60
|
+
const EGRESS_PROXY_ABOUT = "Sends ALL of the computer's outbound TCP through a proxy — exec, terminals, package managers and browsers alike — taken on its host, so nothing inside the computer can opt out. There is no bypass list. It FAILS CLOSED: when the proxy is down or refuses, or its credentials have not reached the host yet (get_computer then says its egress proxy is waiting for credentials), the connection fails and nothing is sent directly. UDP to the internet and ICMP are dropped (QUIC falls back to TCP; NTP and other UDP stop working). DNS lookups are NOT proxied; they go to the platform's resolver. Connections open through the proxy are closed when the setting changes. A host that cannot take it yet — or cannot take an https:// one, or one naming credentials — answers 409 with reason unsupported; one that cannot put it into effect now answers 503, and nothing is changed unless that error says the new setting was stored.";
|
|
13
61
|
/**
|
|
14
62
|
* The answer to a wait the caller ended.
|
|
15
63
|
*
|
|
@@ -207,18 +255,18 @@ const finishedMove = (id, m) => {
|
|
|
207
255
|
*/
|
|
208
256
|
const POWER_NEXT = {
|
|
209
257
|
start: ' wait_for_computer with until="guest" is what says when the desktop is answering.',
|
|
210
|
-
restart: ' wait_for_computer with until="guest" is what says when the desktop is back.',
|
|
258
|
+
restart: ' wait_for_computer with until="guest" is what says when the desktop is back — and, on a computer with secrets bound, when they have been delivered again, where the platform reports that redelivery. Where it does not, the wait can return a few seconds before they land, so a command that finds a secret unset just after a restart is worth retrying.',
|
|
211
259
|
};
|
|
212
260
|
const POWER_DESCRIPTIONS = {
|
|
213
261
|
start: "Boot a computer, or resume a suspended one — a resume restores the saved session, same processes and windows, in about a second. A resumed guest's clock is set to the current time within a few seconds, best effort through its guest agent (one whose agent does not answer keeps the old time until NTP corrects it), and the desktop panel's clock can show the old time for up to a minute after that.",
|
|
214
262
|
stop: 'Shut a computer down: the guest is asked, and given time to do it. On a suspended computer this discards the saved session, and the next start is a fresh boot. The disk is kept. `force` pulls the power instead, for a guest that will not come down on its own.',
|
|
215
263
|
suspend: "Write the guest's RAM to disk and give the host its memory back. A pause, not a stop: start_computer resumes the same session. A computer that holds secrets suspends only on a host that seals saved sessions (its memory is encrypted on the way to disk); elsewhere, and while its secrets are still being delivered, it is refused with 409. After a change to its secret bindings that dropped a secret or moved one to another revision while it ran, it cannot be suspended until it restarts, since its memory may still hold the old value.",
|
|
216
|
-
restart: 'Reset the computer, like the reset button — not a fresh boot, so a change that needs a new machine shape (a resize) needs a stop and a start instead. A suspended computer is refused with 409: start it to resume, or stop it to discard the session. A stopped computer with no secrets bound is started instead; one with secrets bound is refused with 409 while stopped (start it, which delivers them) or while its secrets are being delivered, and otherwise gets its secrets again as it comes back up. A restart issues new desktop credentials and closes open desktop connections, so a get_desktop_url link from before it stops working.',
|
|
264
|
+
restart: 'Reset the computer, like the reset button — not a fresh boot, so a change that needs a new machine shape (a resize) needs a stop and a start instead. A suspended computer is refused with 409: start it to resume, or stop it to discard the session. A stopped computer with no secrets bound is started instead; one with secrets bound is refused with 409 while stopped (start it, which delivers them) or while its secrets are being delivered, and otherwise gets its secrets again as it comes back up, a few seconds after it reads running: wait_for_computer with until="guest" waits for them where the platform reports that redelivery, and where it does not, a command run in those seconds can see them unset. A restart issues new desktop credentials and closes open desktop connections, so a get_desktop_url link from before it stops working.',
|
|
217
265
|
};
|
|
218
266
|
export const registerComputers = (server, session, opts) => {
|
|
219
267
|
server.registerTool('list_templates', {
|
|
220
268
|
title: 'List templates',
|
|
221
|
-
description: 'The base images a computer can be created from — name, OS,
|
|
269
|
+
description: 'The base images a computer can be created from — name, OS, the default CPU and RAM each one implies, and its minimum disk (a smaller disk_gb is raised to it and charged at it). For the templates the platform publishes, `name` is what a create takes as `template`; for one you published yourself, pass its `ref` (namespace/name@version) — a short name is not resolved to your own templates. A short name that is none of the published templates falls back to base, except while this list is INCOMPLETE (a host did not answer): then the create is refused with a retryable 503 that changed nothing, so send it again shortly with the same idempotency_key, or pass a ref to avoid the guess.',
|
|
222
270
|
inputSchema: {},
|
|
223
271
|
annotations: { readOnlyHint: true },
|
|
224
272
|
}, (_args, extra) => guarded(async () => {
|
|
@@ -426,15 +474,25 @@ export const registerComputers = (server, session, opts) => {
|
|
|
426
474
|
// computers somebody else made still has to be able to bring one up, and a
|
|
427
475
|
// stopped computer refuses every other tool here.
|
|
428
476
|
//
|
|
429
|
-
// Four tools around one request, and stop registered on
|
|
430
|
-
//
|
|
431
|
-
// an optional one into the loop would leave the
|
|
477
|
+
// Four tools around one request, with start and stop registered on their
|
|
478
|
+
// own: each has a second argument to take (resume_only, force), and folding
|
|
479
|
+
// an optional one into the loop would leave the others advertising a
|
|
432
480
|
// parameter their route does not read.
|
|
433
481
|
const power = (action, computer_id, extra, opts = {}) => guarded(async () => {
|
|
434
482
|
const id = session.resolve(computer_id);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
483
|
+
// Made once, before the request: the key this call is known by if its
|
|
484
|
+
// answer is lost (OPL-5127).
|
|
485
|
+
const key = idempotencyKeyFor(opts.key);
|
|
486
|
+
let body;
|
|
487
|
+
try {
|
|
488
|
+
body = await session.api.with(extra.signal).json('POST', P.computerAction(id, action), {
|
|
489
|
+
query: opts.query,
|
|
490
|
+
headers: idempotencyHeaders(key),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
return keyedFailure(err, `${action}_computer`, action, key);
|
|
495
|
+
}
|
|
438
496
|
const c = unwrapComputer(body);
|
|
439
497
|
// What the platform documents for all four is an Ack — `{ok: true}` and
|
|
440
498
|
// nothing else — and that is what it sends. Formatting it as a computer
|
|
@@ -447,17 +505,42 @@ export const registerComputers = (server, session, opts) => {
|
|
|
447
505
|
// one: the test for "is this an Ack" is the absence of an id, and a body
|
|
448
506
|
// with no id is not thereby a body with no `vnc`.
|
|
449
507
|
if (!c.id) {
|
|
450
|
-
return said(`${action}: ok — ${id}.${POWER_NEXT[action] ?? ''}${opts.note ?? ''}`, withoutCredentials(c));
|
|
508
|
+
return said(`${action}: ok — ${id}${operationClause(body)}.${POWER_NEXT[action] ?? ''}${opts.note ?? ''}`, withoutCredentials(c));
|
|
451
509
|
}
|
|
452
510
|
session.noteResolution(id, c.resolution);
|
|
453
|
-
return said(`${action}: ${describe(c)}${opts.note ?? ''}`, withoutCredentials(c));
|
|
511
|
+
return said(`${action}: ${describe(c)}${operationClause(body)}${opts.note ?? ''}`, withoutCredentials(c));
|
|
454
512
|
});
|
|
455
|
-
|
|
513
|
+
server.registerTool('start_computer', {
|
|
514
|
+
title: 'Start a computer',
|
|
515
|
+
description: POWER_DESCRIPTIONS.start,
|
|
516
|
+
inputSchema: {
|
|
517
|
+
...idArg,
|
|
518
|
+
resume_only: z
|
|
519
|
+
.boolean()
|
|
520
|
+
.optional()
|
|
521
|
+
.describe('Resume a saved session only, never boot. On a suspended computer it resumes as usual; on a stopped computer with no saved session it SUCCEEDS WITHOUT BOOTING anything, so read get_computer afterwards to see which happened.'),
|
|
522
|
+
idempotency_key: idempotencyKeyArg,
|
|
523
|
+
},
|
|
524
|
+
}, ({ computer_id, resume_only, idempotency_key }, extra) => power('start', computer_id, extra, {
|
|
525
|
+
key: idempotency_key,
|
|
526
|
+
// `enum: ['true']` on the platform, as stop's `force` is: omitted
|
|
527
|
+
// rather than sent as false.
|
|
528
|
+
query: { resume_only: resume_only ? 'true' : undefined },
|
|
529
|
+
note: resume_only
|
|
530
|
+
? '\n\nresume_only: a stopped computer with no saved session is answered the same way without being booted, so this does not say it is running — get_computer or wait_for_computer says which it is.'
|
|
531
|
+
: undefined,
|
|
532
|
+
}));
|
|
533
|
+
for (const action of ['suspend', 'restart']) {
|
|
456
534
|
server.registerTool(`${action}_computer`, {
|
|
457
535
|
title: `${action[0].toUpperCase()}${action.slice(1)} a computer`,
|
|
458
536
|
description: POWER_DESCRIPTIONS[action],
|
|
459
|
-
inputSchema: {
|
|
460
|
-
|
|
537
|
+
inputSchema: {
|
|
538
|
+
...idArg,
|
|
539
|
+
// A restart reads running before and after, so its spent-key route
|
|
540
|
+
// is not "read get_computer" (see resendAfterSpentKey).
|
|
541
|
+
idempotency_key: action === 'restart' ? restartIdempotencyKeyArg : idempotencyKeyArg,
|
|
542
|
+
},
|
|
543
|
+
}, ({ computer_id, idempotency_key }, extra) => power(action, computer_id, extra, { key: idempotency_key }));
|
|
461
544
|
}
|
|
462
545
|
server.registerTool('stop_computer', {
|
|
463
546
|
title: 'Stop a computer',
|
|
@@ -468,8 +551,10 @@ export const registerComputers = (server, session, opts) => {
|
|
|
468
551
|
.boolean()
|
|
469
552
|
.optional()
|
|
470
553
|
.describe('Pull the power instead of asking, the way holding the button in does. Anything the guest had not written to disk is lost, so this is the second attempt and not the first: stop it politely, and reach for `force` when what comes back is a computer still running — a hung X session, a modal "unsaved changes" dialog, or a service that ignores SIGTERM will refuse the polite stop identically every time it is asked.'),
|
|
554
|
+
idempotency_key: idempotencyKeyArg,
|
|
471
555
|
},
|
|
472
|
-
}, ({ computer_id, force }, extra) => power('stop', computer_id, extra, {
|
|
556
|
+
}, ({ computer_id, force, idempotency_key }, extra) => power('stop', computer_id, extra, {
|
|
557
|
+
key: idempotency_key,
|
|
473
558
|
// The platform's schema for this one is `enum: ['true']` — a string,
|
|
474
559
|
// with no false in it — so an unforced stop omits the parameter rather
|
|
475
560
|
// than sending `force=false`, the way `allow_partial` and
|
|
@@ -486,7 +571,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
486
571
|
}));
|
|
487
572
|
server.registerTool('update_computer', {
|
|
488
573
|
title: 'Rename or resize a computer',
|
|
489
|
-
description: "Change a computer's name, its size, or its
|
|
574
|
+
description: "Change a computer's name, its size, its idle window, its browser proxy, or its egress proxy. The platform refuses these in combination on purpose — a resize needs the computer stopped and the others do not, so one request cannot honour both without applying half of it. A SUSPENDED computer counts as stopped for a resize, and its saved desktop cannot survive one: the vCPU count and the memory size are part of the saved state, so it is discarded and the next start is a cold boot. Resume it and finish what is open before resizing, or say so before you do it.",
|
|
490
575
|
inputSchema: {
|
|
491
576
|
...idArg,
|
|
492
577
|
name: z.string().optional(),
|
|
@@ -506,18 +591,39 @@ export const registerComputers = (server, session, opts) => {
|
|
|
506
591
|
.nullable()
|
|
507
592
|
.optional()
|
|
508
593
|
.describe("Minutes untouched before the host suspends it, at most 10080 (a week). 0 disables idle suspend and pressure eviction, up to the plan's limit on computers that never suspend (a 402 beyond it). null returns it to the host's own window. Send this on its own."),
|
|
594
|
+
browser_proxy: browserProxySchema
|
|
595
|
+
.nullable()
|
|
596
|
+
.optional()
|
|
597
|
+
.describe(`${BROWSER_PROXY_ABOUT} Replaces the setting whole; null removes it. To change the bypass of the same proxy, start from get_computer's browser_proxy and copy its credentials_secret_id to keep it: leaving it out removes the credentials, and the proxy then refuses the browsers. Do not carry credentials_secret_id to a different server: those credentials belong to the proxy they were set for; send a new server without it unless the user says those credentials are for that server. Send this on its own. A running computer has it within seconds — wait_for_computer with until="guest" before opening a browser that must use it — and a stopped or suspended one is given it as it starts. A browser already open picks it up at its next start.`),
|
|
598
|
+
egress_proxy: egressProxySchema
|
|
599
|
+
.nullable()
|
|
600
|
+
.optional()
|
|
601
|
+
.describe(`${EGRESS_PROXY_ABOUT} Replaces the setting whole; null removes it and the computer's traffic goes directly again. To change the server of the same proxy, start from the egress proxy get_computer returns and copy its credentials_secret_id to keep it: leaving it out removes the credentials, and the proxy then refuses every connection. Do not carry credentials_secret_id to a different server unless the user says those credentials are for that server. Send this ON ITS OWN — never beside name, a resize, idle_suspend_min or browser_proxy. A running computer has it when the answer arrives; a stopped or suspended one is given it before it starts. After any other 5xx that names no operation_id, send the setting again with the SAME idempotency_key (not a new one or none): the platform most likely refused it before sending it anywhere. After a 5xx that names an operation_id, or no answer, it may or may not have taken effect: call get_computer, and if it does not show the setting you sent, or its egress proxy names credentials and is still waiting for them after a few seconds, send the setting again — with a new idempotency_key, or none, after a 5xx that names an operation_id; with the key the answer named after no answer at all.`),
|
|
602
|
+
idempotency_key: idempotencyKeyArg,
|
|
509
603
|
},
|
|
510
|
-
}, ({ computer_id, ...fields }, extra) => guarded(async () => {
|
|
604
|
+
}, ({ computer_id, idempotency_key, ...fields }, extra) => guarded(async () => {
|
|
511
605
|
const id = session.resolve(computer_id);
|
|
512
|
-
// `null` is meaningful for idle_suspend_min and
|
|
513
|
-
//
|
|
606
|
+
// `null` is meaningful for idle_suspend_min, browser_proxy and
|
|
607
|
+
// egress_proxy and must survive the filter; every other absent field is
|
|
608
|
+
// dropped so the platform leaves it alone.
|
|
514
609
|
const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
610
|
+
if (fields.egress_proxy)
|
|
611
|
+
body.egress_proxy = P.egressProxyBody(fields.egress_proxy);
|
|
515
612
|
if (!Object.keys(body).length) {
|
|
516
|
-
return refused('Nothing to change — give at least one of name, cpu, ram_mb, disk_gb, idle_suspend_min.');
|
|
613
|
+
return refused('Nothing to change — give at least one of name, cpu, ram_mb, disk_gb, idle_suspend_min, browser_proxy, egress_proxy.');
|
|
614
|
+
}
|
|
615
|
+
// The platform refuses it beside anything else; said here, before a
|
|
616
|
+
// request, since nothing about it depends on the computer's state.
|
|
617
|
+
if ('egress_proxy' in body && Object.keys(body).length > 1) {
|
|
618
|
+
const beside = Object.keys(body).filter((k) => k !== 'egress_proxy');
|
|
619
|
+
return refused(`egress_proxy must be sent on its own, not beside ${beside.join(', ')}. Send it in an update_computer call of its own. Nothing was changed.`);
|
|
517
620
|
}
|
|
621
|
+
const key = idempotencyKeyFor(idempotency_key);
|
|
518
622
|
let c;
|
|
519
623
|
try {
|
|
520
|
-
c = unwrapComputer(await session.api
|
|
624
|
+
c = unwrapComputer(await session.api
|
|
625
|
+
.with(extra.signal)
|
|
626
|
+
.json('PATCH', P.computer(id), { body, headers: idempotencyHeaders(key) }));
|
|
521
627
|
}
|
|
522
628
|
catch (err) {
|
|
523
629
|
// The one refusal on this route that is an offer rather than an end
|
|
@@ -527,10 +633,12 @@ export const registerComputers = (server, session, opts) => {
|
|
|
527
633
|
// knowing about.
|
|
528
634
|
if (err instanceof MoveRequiredError)
|
|
529
635
|
return moveOffered(id, err);
|
|
530
|
-
|
|
636
|
+
return keyedFailure(err, 'update_computer', 'change', key);
|
|
531
637
|
}
|
|
532
638
|
session.noteResolution(id, c.resolution);
|
|
533
|
-
|
|
639
|
+
// A resize is recorded as an operation and its answer carries the id;
|
|
640
|
+
// a rename or a setting change carries none, and the clause is empty.
|
|
641
|
+
return said(`${describe(c)}${operationClause(c)}`, withoutCredentials(c));
|
|
534
642
|
}));
|
|
535
643
|
server.registerTool('move_computer', {
|
|
536
644
|
title: 'Move a computer to a host that can run a bigger size',
|
|
@@ -567,8 +675,9 @@ export const registerComputers = (server, session, opts) => {
|
|
|
567
675
|
.max(900)
|
|
568
676
|
.default(300)
|
|
569
677
|
.describe('How long to wait for the move to finish before handing back and letting you poll.'),
|
|
678
|
+
idempotency_key: idempotencyKeyArg,
|
|
570
679
|
},
|
|
571
|
-
}, ({ computer_id, ram_mb, cpu, disk_gb, timeout_s }, extra) => guarded(async () => {
|
|
680
|
+
}, ({ computer_id, ram_mb, cpu, disk_gb, timeout_s, idempotency_key }, extra) => guarded(async () => {
|
|
572
681
|
const id = session.resolve(computer_id);
|
|
573
682
|
const body = {
|
|
574
683
|
ram_mb,
|
|
@@ -587,7 +696,21 @@ export const registerComputers = (server, session, opts) => {
|
|
|
587
696
|
// The 202. Its body is the move as it stood the moment it was accepted,
|
|
588
697
|
// and it is kept because it is the only description of this move that
|
|
589
698
|
// does not depend on a later read succeeding.
|
|
590
|
-
const
|
|
699
|
+
const key = idempotencyKeyFor(idempotency_key);
|
|
700
|
+
let started;
|
|
701
|
+
try {
|
|
702
|
+
started = (await api.json('POST', P.computerAction(id, 'move'), {
|
|
703
|
+
body,
|
|
704
|
+
headers: idempotencyHeaders(key),
|
|
705
|
+
}));
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
return keyedFailure(err, 'move_computer', 'move', key);
|
|
709
|
+
}
|
|
710
|
+
// The rows of GET /moves never carry the operation, so it is carried
|
|
711
|
+
// onto each one this wait reports.
|
|
712
|
+
const operation = operationIdOf(started);
|
|
713
|
+
const tagged = (m) => (operation ? { ...m, operation_id: operation } : m);
|
|
591
714
|
// The keepalive. A disk crossing between two hosts is minutes, and a
|
|
592
715
|
// tool that says nothing for minutes is one a client cancels — see
|
|
593
716
|
// heartbeat, and OPL-4579 for the wait that proved it.
|
|
@@ -671,9 +794,9 @@ export const registerComputers = (server, session, opts) => {
|
|
|
671
794
|
await sleep(POLL_MS, signal);
|
|
672
795
|
continue;
|
|
673
796
|
}
|
|
674
|
-
last = mine;
|
|
797
|
+
last = tagged(mine);
|
|
675
798
|
if (!mine.live)
|
|
676
|
-
return finishedMove(id,
|
|
799
|
+
return finishedMove(id, last);
|
|
677
800
|
await beat(`Moving ${id} — ${mine.state}${mine.detail ? `: ${mine.detail}` : ''}`);
|
|
678
801
|
await sleep(POLL_MS, signal);
|
|
679
802
|
}
|
|
@@ -744,13 +867,13 @@ export const registerComputers = (server, session, opts) => {
|
|
|
744
867
|
}));
|
|
745
868
|
server.registerTool('wait_for_computer', {
|
|
746
869
|
title: 'Wait for a computer to be ready',
|
|
747
|
-
description: 'Poll until the computer is running, or until the software inside it answers. Use "guest" before exec, files or windows, and before expecting a screenshot to show a desktop rather than a boot screen. Reports progress while it waits, so a client that sends a progressToken and sets resetTimeoutOnProgress can hold the request open; a client that cannot should lower timeout_s and call again rather than watch its own default timeout cancel the wait.',
|
|
870
|
+
description: 'Poll until the computer is running, or until the software inside it answers. Use "guest" before exec, files or windows, and before expecting a screenshot to show a desktop rather than a boot screen; on a computer with secrets bound, "guest" also waits until they have reached the desktop, so a command run next sees them, on one whose browser proxy is being set or removed it waits until its browsers have the change, and on one whose egress proxy is waiting for its credentials it waits until they arrive, since every connection the computer opens meanwhile is closed. Reports progress while it waits, so a client that sends a progressToken and sets resetTimeoutOnProgress can hold the request open; a client that cannot should lower timeout_s and call again rather than watch its own default timeout cancel the wait.',
|
|
748
871
|
inputSchema: {
|
|
749
872
|
...idArg,
|
|
750
873
|
until: z
|
|
751
874
|
.enum(['running', 'guest'])
|
|
752
875
|
.default('guest')
|
|
753
|
-
.describe('"running" is the hypervisor reporting the VM up. "guest" is the software inside it answering, which is what exec and a painted desktop actually need.'),
|
|
876
|
+
.describe('"running" is the hypervisor reporting the VM up. "guest" is the software inside it answering, which is what exec and a painted desktop actually need — and, on a computer with secrets bound, its secrets delivered, on one with a browser proxy, its browsers holding it, and on one with an egress proxy that names credentials, its host holding them.'),
|
|
754
877
|
timeout_s: z.number().int().min(5).max(900).default(180),
|
|
755
878
|
},
|
|
756
879
|
// No readOnlyHint — this is not a pure read, so the hint would overclaim —
|
|
@@ -791,6 +914,15 @@ export const registerComputers = (server, session, opts) => {
|
|
|
791
914
|
// report, and swallowing every transient would end the wait saying only
|
|
792
915
|
// that the status was never seen.
|
|
793
916
|
let blocked;
|
|
917
|
+
// Which proxy flag, if any, held the last status read, so the give-up
|
|
918
|
+
// can name it. Only the last read counts, so a wait that ran out on
|
|
919
|
+
// egress_proxy_pending may have met an ordinary delivery: the give-up
|
|
920
|
+
// invites another wait first, and names what clears a flag that
|
|
921
|
+
// persists (a deleted credentials secret, an update whose answer was
|
|
922
|
+
// lost) as the fallback. browser_proxy_pending is named too, so a
|
|
923
|
+
// wait held on it does not
|
|
924
|
+
// end saying only "last seen running".
|
|
925
|
+
let heldOn;
|
|
794
926
|
while (!untilDeadline.aborted) {
|
|
795
927
|
// The caller giving up ends the wait. The signal aborts the request
|
|
796
928
|
// in flight, but nothing about an aborted request stops the next
|
|
@@ -835,6 +967,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
835
967
|
continue;
|
|
836
968
|
}
|
|
837
969
|
blocked = undefined;
|
|
970
|
+
heldOn = undefined;
|
|
838
971
|
session.noteResolution(id, c.resolution);
|
|
839
972
|
last = c.status ?? 'unknown';
|
|
840
973
|
// ONE beat per turn, and this is not it when a guest probe is about to
|
|
@@ -898,11 +1031,44 @@ export const registerComputers = (server, session, opts) => {
|
|
|
898
1031
|
return refused(`${id} is suspended, and that state does not clear by itself. start_computer resumes the saved session in about a second.`, withoutCredentials(c));
|
|
899
1032
|
}
|
|
900
1033
|
if (last === 'stopped' && nothingAdmitted(c)) {
|
|
901
|
-
|
|
1034
|
+
const why = typeof c.secrets_error === 'string' && c.secrets_error.trim()
|
|
1035
|
+
? ` Its secrets were not delivered, so the platform stopped it: ${c.secrets_error.trim()}.`
|
|
1036
|
+
: '';
|
|
1037
|
+
return refused(`${id} is stopped.${why} start_computer boots it.`, withoutCredentials(c));
|
|
902
1038
|
}
|
|
903
1039
|
if (last === 'running') {
|
|
904
1040
|
if (until === 'running')
|
|
905
1041
|
return said(`Running: ${describe(c)}`, withoutCredentials(c));
|
|
1042
|
+
// A bound computer runs, and its guest answers, a few seconds
|
|
1043
|
+
// before its secrets land — and a command run in between sees
|
|
1044
|
+
// them unset. "guest" is what a caller waits on before exec, so
|
|
1045
|
+
// it waits for those too. A delivery that fails stops the
|
|
1046
|
+
// computer, which the stopped refusal below then reports. On a
|
|
1047
|
+
// platform without secrets_delivering, the receipt says instead.
|
|
1048
|
+
if (secretsOnTheirWay(c)) {
|
|
1049
|
+
await beat(`Waiting for ${id} — running; its secrets are still on their way.`);
|
|
1050
|
+
await sleep(POLL_MS, signal);
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
// The same gap for a browser proxy: the guest answers before the
|
|
1054
|
+
// policy is on disk, and a browser opened in between goes out
|
|
1055
|
+
// directly. Never pending on a computer that is not running, so
|
|
1056
|
+
// this is the only place it can be waited on.
|
|
1057
|
+
if (c.browser_proxy_pending === true) {
|
|
1058
|
+
heldOn = 'browser';
|
|
1059
|
+
await beat(`Waiting for ${id} — running; its browser proxy is still being applied.`);
|
|
1060
|
+
await sleep(POLL_MS, signal);
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
// And for an egress proxy whose credentials have not reached the
|
|
1064
|
+
// host: the guest answers, but every connection it opens is
|
|
1065
|
+
// closed until they do, so a command run next cannot reach out.
|
|
1066
|
+
if (c.egress_proxy_pending === true) {
|
|
1067
|
+
heldOn = 'egress';
|
|
1068
|
+
await beat(`Waiting for ${id} — running; waiting for the egress proxy's credentials (egress_proxy_pending), which usually arrive within seconds.`);
|
|
1069
|
+
await sleep(POLL_MS, signal);
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
906
1072
|
// "The guest is up" is not a status the platform reports, so it is
|
|
907
1073
|
// asked rather than waited for: a trivial exec either answers, or
|
|
908
1074
|
// refuses with the 409 that says the agent is not up yet.
|
|
@@ -967,9 +1133,21 @@ export const registerComputers = (server, session, opts) => {
|
|
|
967
1133
|
// which is the same shape of answer as a cancellation and not the same
|
|
968
1134
|
// as success. The message still says to call again, because the state
|
|
969
1135
|
// it was waiting on may yet arrive.
|
|
1136
|
+
//
|
|
1137
|
+
// Behind an egress proxy still waiting for its credentials, the
|
|
1138
|
+
// answer names that state. It still says to call again first: the
|
|
1139
|
+
// flag is read afresh on every poll, so a wait that ran out on it may
|
|
1140
|
+
// have met an ordinary delivery a few seconds long. Only a flag that
|
|
1141
|
+
// persists across waits points at something the caller has to fix,
|
|
1142
|
+
// and even then removing the proxy is the user's call, not a fix: it
|
|
1143
|
+
// sends every connection directly.
|
|
970
1144
|
return refused(blocked
|
|
971
1145
|
? `Gave up after ${timeout_s}s; the platform could not be asked about ${id} for the whole wait — the last attempt said: ${blocked}. Nothing was changed — call again to keep waiting.`
|
|
972
|
-
:
|
|
1146
|
+
: heldOn === 'egress'
|
|
1147
|
+
? `Gave up after ${timeout_s}s; ${id} is running, but its egress proxy's credentials have not reached its host yet (egress_proxy_pending), so every connection it opens is closed until they do. Nothing was changed — call again to keep waiting. If it persists across waits, the secret its egress_proxy.credentials_secret_id names may have been deleted, or an earlier update_computer egress_proxy change may have got a 5xx or no answer: send the egress_proxy setting again with update_computer, naming a secret that exists (list_secrets). Removing the proxy with null is for the user to decide, not a fix: it sends all of the computer's traffic directly.`
|
|
1148
|
+
: heldOn === 'browser'
|
|
1149
|
+
? `Gave up after ${timeout_s}s; ${id} is running, but its browser proxy was still being applied in the guest (browser_proxy_pending), so a browser opened now may not use it. Nothing was changed — call again to keep waiting; if it persists, send the browser_proxy setting again with update_computer.`
|
|
1150
|
+
: `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
|
|
973
1151
|
}
|
|
974
1152
|
finally {
|
|
975
1153
|
await beat.stop();
|
|
@@ -1132,7 +1310,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1132
1310
|
return;
|
|
1133
1311
|
server.registerTool('create_computer', {
|
|
1134
1312
|
title: 'Create a computer',
|
|
1135
|
-
description:
|
|
1313
|
+
description: "Build a new cloud desktop and select it for this session. Creating and running a computer costs money on this account. Continue an image preparation refusal only as its result instructs, retaining all original create arguments, including template, and adding the returned preparation token as an argument. This token is not a create idempotency key. Stop after success; never automatically replay after a lost or ambiguous response without the idempotency_key that response names — with it, the same call answers the first one's result instead of building a second computer.",
|
|
1136
1314
|
inputSchema: {
|
|
1137
1315
|
name: z.string().optional().describe('A label. The platform picks one if you do not.'),
|
|
1138
1316
|
size: z
|
|
@@ -1142,7 +1320,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1142
1320
|
template: z
|
|
1143
1321
|
.string()
|
|
1144
1322
|
.optional()
|
|
1145
|
-
.describe('From list_templates: the short name of a template the platform publishes, e.g. "base" for Linux/Xfce, or a pinned ref (namespace/name@version) — which a template you published must be named by. A short name the
|
|
1323
|
+
.describe('From list_templates: the short name of a template the platform publishes, e.g. "base" for Linux/Xfce, or a pinned ref (namespace/name@version) — which a template you published must be named by. A short name that is none of the published templates falls back to base — except while list_templates is INCOMPLETE (a host did not answer), when the create is refused with a retryable 503 that changed nothing; send it again shortly with the same idempotency_key, or pass a ref to avoid the guess. A ref that names nothing is refused. Defaults to base.'),
|
|
1146
1324
|
template_transfer: z
|
|
1147
1325
|
.string()
|
|
1148
1326
|
.refine((value) => value.trim().length > 0, 'template_transfer must not be blank')
|
|
@@ -1163,15 +1341,25 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1163
1341
|
start: z.boolean().optional().describe('Boot it immediately. True by default.'),
|
|
1164
1342
|
secrets: secretBindingsSchema(false)
|
|
1165
1343
|
.optional()
|
|
1166
|
-
.describe(`Secrets from the account to deliver into the desktop session each time the computer starts, each as an environment variable (\`env\`) or as a file under ${FILES_DIR} (\`file\`). Only ids and names are sent — never a value. Secret ids come from list_secrets (create_secret stores a new one). Linux only, and only on a template whose image can receive them. A value replaced later reaches the running computer live when bound as a file, and as a variable on an image that supports it reaches new shells and exec with desktop: true. get_computer_secrets and set_computer_secrets read and change the bindings later.`),
|
|
1344
|
+
.describe(`Secrets from the account to deliver into the desktop session each time the computer starts, each as an environment variable (\`env\`) or as a file under ${FILES_DIR} (\`file\`). Only ids and names are sent — never a value. Secret ids come from list_secrets (create_secret stores a new one). Linux only, and only on a template whose image can receive them. A value replaced later reaches the running computer live when bound as a file, and as a variable on an image that supports it reaches new shells and every exec, plain or with desktop: true. get_computer_secrets and set_computer_secrets read and change the bindings later.`),
|
|
1345
|
+
browser_proxy: browserProxySchema
|
|
1346
|
+
.nullable()
|
|
1347
|
+
.optional()
|
|
1348
|
+
.describe(`${BROWSER_PROXY_ABOUT} A create carrying one is always a cold boot. A credentials_secret_id must name a secret bound as a file in this create's secrets. A template you published may carry a default browser proxy, which the computer inherits when this is left out; send another proxy to use that instead, or null to create the computer with none. wait_for_computer with until="guest" waits until its browsers have it; update_computer changes or removes it later.`),
|
|
1349
|
+
egress_proxy: egressProxySchema
|
|
1350
|
+
.optional()
|
|
1351
|
+
.describe(`${EGRESS_PROXY_ABOUT} It is in effect from the first packet the computer sends: until a proxy's credentials reach its host, connections are closed, not sent directly (wait_for_computer with until="guest" waits for them). A create carrying one is never answered from the warm pool, and a clone does not inherit it. A credentials_secret_id naming no secret, or one whose value is not user:password, is refused with 400 before anything is created. update_computer changes or removes it later.`),
|
|
1352
|
+
idempotency_key: buildIdempotencyKeyArg,
|
|
1167
1353
|
},
|
|
1168
1354
|
annotations: { destructiveHint: false, openWorldHint: true },
|
|
1169
|
-
}, (args, extra) => guarded(async () => {
|
|
1355
|
+
}, ({ idempotency_key, ...args }, extra) => guarded(async () => {
|
|
1356
|
+
const key = idempotencyKeyFor(idempotency_key);
|
|
1170
1357
|
let data;
|
|
1171
1358
|
try {
|
|
1172
|
-
data = await session.api
|
|
1173
|
-
.
|
|
1174
|
-
|
|
1359
|
+
data = await session.api.with(extra.signal).json('POST', P.COMPUTERS, {
|
|
1360
|
+
body: P.createBody(args),
|
|
1361
|
+
headers: idempotencyHeaders(key),
|
|
1362
|
+
});
|
|
1175
1363
|
}
|
|
1176
1364
|
catch (error) {
|
|
1177
1365
|
const body = error instanceof ConflictError
|
|
@@ -1213,7 +1401,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1213
1401
|
],
|
|
1214
1402
|
};
|
|
1215
1403
|
}
|
|
1216
|
-
|
|
1404
|
+
return keyedFailure(error, 'create_computer', 'computer', key);
|
|
1217
1405
|
}
|
|
1218
1406
|
const c = unwrapComputer(data);
|
|
1219
1407
|
// Selection and the sentence claiming it are the same decision. Bound
|
|
@@ -1230,8 +1418,8 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1230
1418
|
// with the reason on it. Saying so plainly is the difference between a
|
|
1231
1419
|
// model retrying the start and a model creating a second computer.
|
|
1232
1420
|
const note = c.start_error
|
|
1233
|
-
? `Created ${describe(c)}, but it did not start: ${c.start_error}\nThe computer exists and is selected. start_computer often works on a second attempt.`
|
|
1234
|
-
: `Created and selected ${describe(c)}.`;
|
|
1421
|
+
? `Created ${describe(c)}${operationClause(c)}, but it did not start: ${c.start_error}\nThe computer exists and is selected. start_computer often works on a second attempt.`
|
|
1422
|
+
: `Created and selected ${describe(c)}${operationClause(c)}.`;
|
|
1235
1423
|
return said(args.template_transfer === undefined
|
|
1236
1424
|
? note
|
|
1237
1425
|
: `${note} Stop retrying this create; the template_transfer token is not a create idempotency key.`, withoutCredentials(c));
|
|
@@ -1242,16 +1430,31 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1242
1430
|
inputSchema: {
|
|
1243
1431
|
...idArg,
|
|
1244
1432
|
name: z.string().optional().describe('A name for the copy.'),
|
|
1433
|
+
idempotency_key: buildIdempotencyKeyArg,
|
|
1245
1434
|
},
|
|
1246
|
-
}, ({ computer_id, name }, extra) => guarded(async () => {
|
|
1435
|
+
}, ({ computer_id, name, idempotency_key }, extra) => guarded(async () => {
|
|
1247
1436
|
const id = session.resolve(computer_id);
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1250
|
-
|
|
1437
|
+
const key = idempotencyKeyFor(idempotency_key);
|
|
1438
|
+
let answer;
|
|
1439
|
+
try {
|
|
1440
|
+
answer = await session.api
|
|
1441
|
+
.with(extra.signal)
|
|
1442
|
+
.json('POST', P.computerAction(id, 'clone'), {
|
|
1443
|
+
body: name === undefined ? {} : { name },
|
|
1444
|
+
headers: idempotencyHeaders(key),
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
catch (err) {
|
|
1448
|
+
return keyedFailure(err, 'clone_computer', 'copy', key);
|
|
1449
|
+
}
|
|
1450
|
+
const c = unwrapComputer(answer);
|
|
1251
1451
|
if (!c.id) {
|
|
1252
1452
|
return refused(`The platform accepted the clone of ${id} but sent no id back, so the copy cannot be identified. It may exist and be billable — list_computers will say. The original stays selected.`, withoutCredentials(c));
|
|
1253
1453
|
}
|
|
1254
|
-
return said(`Cloned ${id} to ${describe(c)}. The original stays selected; use_computer to switch
|
|
1454
|
+
return said(`Cloned ${id} to ${describe(c)}${operationClause(c)}. The original stays selected; use_computer to switch.` +
|
|
1455
|
+
(c.operation_id
|
|
1456
|
+
? ` wait_for_operation with ${c.operation_id} answers when its disk has been copied.`
|
|
1457
|
+
: ''), withoutCredentials(c));
|
|
1255
1458
|
}));
|
|
1256
1459
|
server.registerTool('delete_computer', {
|
|
1257
1460
|
title: 'Delete a computer',
|
|
@@ -1271,9 +1474,10 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1271
1474
|
.string()
|
|
1272
1475
|
.optional()
|
|
1273
1476
|
.describe('The fingerprint from snapshot_holdings. The purge is refused unless it still names the same set, so a capture that finished after you looked cannot be swept up in a decision that was never about it.'),
|
|
1477
|
+
idempotency_key: idempotencyKeyArg,
|
|
1274
1478
|
},
|
|
1275
1479
|
annotations: { destructiveHint: true, idempotentHint: true },
|
|
1276
|
-
}, ({ computer_id, delete_snapshots, expect }, extra) => guarded(async () => {
|
|
1480
|
+
}, ({ computer_id, delete_snapshots, expect, idempotency_key }, extra) => guarded(async () => {
|
|
1277
1481
|
// The platform makes `expect` optional, for callers that cannot read the
|
|
1278
1482
|
// holdings and so were never shown a set to be held to. An MCP caller
|
|
1279
1483
|
// can read them — snapshot_holdings is right there — so here it is
|
|
@@ -1291,6 +1495,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1291
1495
|
'check that the count and size are what you meant to destroy, and pass its fingerprint as `expect`. ' +
|
|
1292
1496
|
'Nothing has been deleted.');
|
|
1293
1497
|
}
|
|
1498
|
+
const key = idempotencyKeyFor(idempotency_key);
|
|
1294
1499
|
let res;
|
|
1295
1500
|
try {
|
|
1296
1501
|
res = await session.api
|
|
@@ -1300,6 +1505,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1300
1505
|
snapshots: delete_snapshots ? 'delete' : undefined,
|
|
1301
1506
|
expect: delete_snapshots ? fingerprint : undefined,
|
|
1302
1507
|
},
|
|
1508
|
+
headers: idempotencyHeaders(key),
|
|
1303
1509
|
});
|
|
1304
1510
|
}
|
|
1305
1511
|
catch (err) {
|
|
@@ -1338,7 +1544,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1338
1544
|
if (absent && !signal.aborted)
|
|
1339
1545
|
session.unbind(computer_id);
|
|
1340
1546
|
}
|
|
1341
|
-
|
|
1547
|
+
return keyedFailure(err, 'delete_computer', 'delete', key);
|
|
1342
1548
|
}
|
|
1343
1549
|
session.unbind(computer_id);
|
|
1344
1550
|
// Reported as a success rather than an error, and deliberately not as
|
|
@@ -1367,9 +1573,49 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1367
1573
|
const purged = res?.snapshots_deleted === undefined
|
|
1368
1574
|
? 'its snapshots'
|
|
1369
1575
|
: `${res.snapshots_deleted} of its snapshot(s)`;
|
|
1576
|
+
// The delete's operation (kind `delete`), where the platform recorded
|
|
1577
|
+
// one, said like every other lifecycle tool's and kept in the
|
|
1578
|
+
// structured answer so a caller can read it back with get_operation.
|
|
1579
|
+
const op = operationIdOf(res);
|
|
1580
|
+
const done = `Deleted ${computer_id}${operationClause(res)}`;
|
|
1581
|
+
// A purge the platform could not finish in the call (OPL-5437): it
|
|
1582
|
+
// answers 202 with ok:false, the computer deleted, and a count of the
|
|
1583
|
+
// copies still queued, refused or unknown. Reported as done-and-
|
|
1584
|
+
// incomplete, never as a clean purge, and kept whole in the answer.
|
|
1585
|
+
if (delete_snapshots && res?.ok === false) {
|
|
1586
|
+
const purge = isPlainRecord(res.purge) ? res.purge : {};
|
|
1587
|
+
const counts = ['queued', 'failed', 'unknown', 'remaining']
|
|
1588
|
+
.filter((k) => typeof purge[k] === 'number')
|
|
1589
|
+
.map((k) => `${k} ${purge[k]}`)
|
|
1590
|
+
.join(', ');
|
|
1591
|
+
const why = typeof res.error === 'string' && res.error.trim() ? res.error.trim() : undefined;
|
|
1592
|
+
return said(`${done}, but the snapshot purge is INCOMPLETE${counts ? ` (${counts})` : ''}${why ? `: "${why}"` : ''}. ` +
|
|
1593
|
+
`The computer is deleted; some of its snapshot copies may still exist. Read snapshot_holdings for ${computer_id} before retrying anything.`, {
|
|
1594
|
+
computer_id,
|
|
1595
|
+
ok: false,
|
|
1596
|
+
...(op ? { operation_id: op } : {}),
|
|
1597
|
+
...(res.computer_deleted !== undefined
|
|
1598
|
+
? { computer_deleted: res.computer_deleted }
|
|
1599
|
+
: {}),
|
|
1600
|
+
...(res.snapshots_deleted === undefined
|
|
1601
|
+
? {}
|
|
1602
|
+
: { snapshots_deleted: res.snapshots_deleted }),
|
|
1603
|
+
...(res.purge !== undefined ? { purge: res.purge } : {}),
|
|
1604
|
+
...(why ? { error: why } : {}),
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1370
1607
|
return said(delete_snapshots
|
|
1371
|
-
?
|
|
1372
|
-
:
|
|
1608
|
+
? `${done} and ${purged}.`
|
|
1609
|
+
: `${done}. Its disk is gone; any snapshots it had remain, as orphans that can be cloned but not restored.`, op
|
|
1610
|
+
? {
|
|
1611
|
+
computer_id,
|
|
1612
|
+
operation_id: op,
|
|
1613
|
+
...(res?.snapshots_deleted === undefined
|
|
1614
|
+
? {}
|
|
1615
|
+
: { snapshots_deleted: res.snapshots_deleted }),
|
|
1616
|
+
}
|
|
1617
|
+
: undefined);
|
|
1373
1618
|
}));
|
|
1374
1619
|
};
|
|
1620
|
+
const isPlainRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
1375
1621
|
//# sourceMappingURL=computers.js.map
|