mandala-computer-mcp 0.4.0 → 0.6.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 +558 -29
- package/dist/api.d.ts +59 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +404 -33
- package/dist/api.js.map +1 -1
- package/dist/artifacts.d.ts +63 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +81 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +52 -21
- package/dist/cli.js.map +1 -1
- package/dist/credentials.d.ts +33 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +395 -0
- package/dist/credentials.js.map +1 -0
- package/dist/errors.d.ts +122 -11
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +237 -41
- package/dist/errors.js.map +1 -1
- package/dist/executions.d.ts +43 -0
- package/dist/executions.d.ts.map +1 -0
- package/dist/executions.js +162 -0
- package/dist/executions.js.map +1 -0
- package/dist/format.d.ts +33 -8
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +107 -12
- package/dist/format.js.map +1 -1
- package/dist/http.d.ts +42 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +383 -3
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +50 -2
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +65 -5
- package/dist/paths.js.map +1 -1
- package/dist/results.d.ts +97 -0
- package/dist/results.d.ts.map +1 -0
- package/dist/results.js +263 -0
- package/dist/results.js.map +1 -0
- package/dist/secret-errors.d.ts +59 -0
- package/dist/secret-errors.d.ts.map +1 -0
- package/dist/secret-errors.js +199 -0
- package/dist/secret-errors.js.map +1 -0
- package/dist/secret-store.d.ts +115 -0
- package/dist/secret-store.d.ts.map +1 -0
- package/dist/secret-store.js +105 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/server.d.ts +3 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +60 -9
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts +6 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +1 -1
- package/dist/session.js.map +1 -1
- package/dist/stdio.d.ts +5 -1
- package/dist/stdio.d.ts.map +1 -1
- package/dist/stdio.js +10 -4
- package/dist/stdio.js.map +1 -1
- package/dist/tool-filters.d.ts +38 -0
- package/dist/tool-filters.d.ts.map +1 -0
- package/dist/tool-filters.js +137 -0
- package/dist/tool-filters.js.map +1 -0
- package/dist/tools/account.d.ts +3 -0
- package/dist/tools/account.d.ts.map +1 -0
- package/dist/tools/account.js +122 -0
- package/dist/tools/account.js.map +1 -0
- package/dist/tools/activities.d.ts +3 -0
- package/dist/tools/activities.d.ts.map +1 -0
- package/dist/tools/activities.js +140 -0
- package/dist/tools/activities.js.map +1 -0
- package/dist/tools/agent.d.ts.map +1 -1
- package/dist/tools/agent.js +21 -9
- package/dist/tools/agent.js.map +1 -1
- package/dist/tools/artifacts.d.ts +3 -0
- package/dist/tools/artifacts.d.ts.map +1 -0
- package/dist/tools/artifacts.js +97 -0
- package/dist/tools/artifacts.js.map +1 -0
- package/dist/tools/chat.d.ts +6 -0
- package/dist/tools/chat.d.ts.map +1 -0
- package/dist/tools/chat.js +192 -0
- package/dist/tools/chat.js.map +1 -0
- package/dist/tools/computers.d.ts.map +1 -1
- package/dist/tools/computers.js +28 -16
- package/dist/tools/computers.js.map +1 -1
- package/dist/tools/directory.d.ts +13 -0
- package/dist/tools/directory.d.ts.map +1 -0
- package/dist/tools/directory.js +88 -0
- package/dist/tools/directory.js.map +1 -0
- package/dist/tools/executions.d.ts +3 -0
- package/dist/tools/executions.d.ts.map +1 -0
- package/dist/tools/executions.js +87 -0
- package/dist/tools/executions.js.map +1 -0
- package/dist/tools/guest.d.ts.map +1 -1
- package/dist/tools/guest.js +184 -35
- package/dist/tools/guest.js.map +1 -1
- package/dist/tools/input.d.ts +2 -0
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +31 -5
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/results.d.ts +30 -0
- package/dist/tools/results.d.ts.map +1 -0
- package/dist/tools/results.js +106 -0
- package/dist/tools/results.js.map +1 -0
- package/dist/tools/secrets.d.ts +78 -0
- package/dist/tools/secrets.d.ts.map +1 -0
- package/dist/tools/secrets.js +448 -0
- package/dist/tools/secrets.js.map +1 -0
- package/dist/tools/signals.d.ts +3 -0
- package/dist/tools/signals.d.ts.map +1 -0
- package/dist/tools/signals.js +116 -0
- package/dist/tools/signals.js.map +1 -0
- package/dist/tools/snapshots.d.ts.map +1 -1
- package/dist/tools/snapshots.js +52 -10
- package/dist/tools/snapshots.js.map +1 -1
- package/dist/tools/ssh.d.ts +3 -0
- package/dist/tools/ssh.d.ts.map +1 -0
- package/dist/tools/ssh.js +186 -0
- package/dist/tools/ssh.js.map +1 -0
- package/dist/tools/webhooks.js +1 -1
- package/dist/tools/webhooks.js.map +1 -1
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -1,8 +1,48 @@
|
|
|
1
1
|
import { Agent, Headers as UndiciHeaders, fetch as undiciFetch } from 'undici';
|
|
2
|
-
import { CancelledError, ConnectivityError, ConnectivityInterruptedError, errorForStatus, MandalaError, RangeNotSatisfiableError, RedirectError, } from './errors.js';
|
|
2
|
+
import { CancelledError, ConnectivityError, ConnectivityInterruptedError, createOnlyRefusal, errorForStatus, isCreateOnlyUpload, MandalaError, platformSaid, RangeNotSatisfiableError, RedirectError, } from './errors.js';
|
|
3
|
+
import * as P from './paths.js';
|
|
4
|
+
import { MalformedSecretAnswerError, sanitizeSecretError, secretRouteOf, } from './secret-errors.js';
|
|
5
|
+
import { DOCUMENTED_REASONS, secretListOf, secretOf } from './secret-store.js';
|
|
6
|
+
export { isSecretStoreRoute } from './secret-errors.js';
|
|
3
7
|
export const DEFAULT_BASE_URL = 'https://app.mandala.computer/api/v1';
|
|
4
8
|
/** Anthropic's own key, forwarded for the one route that runs a model. */
|
|
5
9
|
export const MODEL_KEY_HEADER = 'X-Model-Key';
|
|
10
|
+
/**
|
|
11
|
+
* Proof, to the platform, that a request comes through the hosted MCP service
|
|
12
|
+
* (OPL-4982). The platform takes an OAuth access token only alongside it, so a
|
|
13
|
+
* token cannot be replayed at the API directly by the app it was issued to.
|
|
14
|
+
*
|
|
15
|
+
* Only this server's own value is ever sent. A header of this name arriving
|
|
16
|
+
* from anywhere else — a caller, or a per-call header set — is dropped in
|
|
17
|
+
* `#fetch`, so a client cannot supply the secret or learn anything by trying.
|
|
18
|
+
*/
|
|
19
|
+
export const SERVICE_HEADER = 'X-Mandala-MCP-Service';
|
|
20
|
+
/**
|
|
21
|
+
* All that is kept of a secret-store refusal's body: its `reason`, if it is a
|
|
22
|
+
* word. Nothing else, and never a sentence.
|
|
23
|
+
*/
|
|
24
|
+
function secretStoreRefusalBody(text) {
|
|
25
|
+
if (!text)
|
|
26
|
+
return undefined;
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(text);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
35
|
+
return undefined;
|
|
36
|
+
const reason = parsed.reason;
|
|
37
|
+
return typeof reason === 'string' && DOCUMENTED_REASONS.has(reason) ? { reason } : undefined;
|
|
38
|
+
}
|
|
39
|
+
function responseMetadata(resp) {
|
|
40
|
+
return {
|
|
41
|
+
requestId: resp.headers.get('x-request-id') ?? undefined,
|
|
42
|
+
allow: resp.headers.get('allow') ?? undefined,
|
|
43
|
+
wwwAuthenticate: resp.headers.get('www-authenticate') ?? undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
6
46
|
/**
|
|
7
47
|
* How much of an event stream will be held while waiting for a boundary.
|
|
8
48
|
*
|
|
@@ -28,6 +68,10 @@ const MAX_SSE_BUFFER = 8 * 1024 * 1024;
|
|
|
28
68
|
*/
|
|
29
69
|
const MAX_JSON_BODY_BYTES = 48 * 1024 * 1024;
|
|
30
70
|
const MAX_ERROR_BODY_BYTES = 1024 * 1024;
|
|
71
|
+
/** Whether a `WWW-Authenticate` value challenges for a Bearer credential. */
|
|
72
|
+
function bearerChallenged(value) {
|
|
73
|
+
return value !== null && /^\s*bearer(?:\s|,|$)/i.test(value);
|
|
74
|
+
}
|
|
31
75
|
/**
|
|
32
76
|
* The longest foreground guest exec waits 600 seconds before it answers.
|
|
33
77
|
* Node's bundled fetch gives response headers 300 seconds by default, so the
|
|
@@ -84,18 +128,6 @@ const NATIVE_FETCH = globalThis.fetch;
|
|
|
84
128
|
export const platformFetch = () => globalThis.fetch === NATIVE_FETCH
|
|
85
129
|
? undiciFetch
|
|
86
130
|
: globalThis.fetch;
|
|
87
|
-
/**
|
|
88
|
-
* The transport for one API key.
|
|
89
|
-
*
|
|
90
|
-
* One per MCP session rather than one per process, because the HTTP transport
|
|
91
|
-
* authenticates each caller with their own `com_…` key and two sessions must
|
|
92
|
-
* never share a client. See `src/session.ts`.
|
|
93
|
-
*
|
|
94
|
-
* The key lives in this object's closure and is never put on an error, a log
|
|
95
|
-
* line, or a tool result. That is not paranoia about our own code: an MCP tool
|
|
96
|
-
* result goes into a model's context and from there into transcripts, and an
|
|
97
|
-
* API key is every computer on the account, forever.
|
|
98
|
-
*/
|
|
99
131
|
export class Api {
|
|
100
132
|
baseUrl;
|
|
101
133
|
/** The same thing parsed, so a path is joined onto the path and nothing else. */
|
|
@@ -104,7 +136,8 @@ export class Api {
|
|
|
104
136
|
#headers;
|
|
105
137
|
/** Applied to every request that does not carry one of its own. See `with`. */
|
|
106
138
|
#signal;
|
|
107
|
-
|
|
139
|
+
#options;
|
|
140
|
+
constructor(apiKey, baseUrl = DEFAULT_BASE_URL, signal, options = {}) {
|
|
108
141
|
if (!apiKey) {
|
|
109
142
|
throw new MandalaError('No API key. Set MANDALA_API_KEY (create one at Settings → API keys), ' +
|
|
110
143
|
'or send it as a bearer token when running over HTTP.');
|
|
@@ -145,6 +178,7 @@ export class Api {
|
|
|
145
178
|
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
146
179
|
this.#apiKey = apiKey;
|
|
147
180
|
this.#signal = signal;
|
|
181
|
+
this.#options = options;
|
|
148
182
|
this.#headers = {
|
|
149
183
|
Authorization: `Bearer ${apiKey}`,
|
|
150
184
|
Accept: 'application/json',
|
|
@@ -165,7 +199,7 @@ export class Api {
|
|
|
165
199
|
with(signal) {
|
|
166
200
|
if (!signal || signal === this.#signal)
|
|
167
201
|
return this;
|
|
168
|
-
return new Api(this.#apiKey, this.baseUrl, signal);
|
|
202
|
+
return new Api(this.#apiKey, this.baseUrl, signal, this.#options);
|
|
169
203
|
}
|
|
170
204
|
#url(path, query) {
|
|
171
205
|
const url = new URL(this.#base);
|
|
@@ -189,8 +223,72 @@ export class Api {
|
|
|
189
223
|
}
|
|
190
224
|
return url.toString();
|
|
191
225
|
}
|
|
192
|
-
|
|
193
|
-
|
|
226
|
+
/**
|
|
227
|
+
* The secret-store route a path lands on, judged on the URL it is actually
|
|
228
|
+
* sent to — after the same joining and dot-segment normalization dispatch
|
|
229
|
+
* uses, and percent-decoded — so `//secrets`, `./secrets` and
|
|
230
|
+
* `x/../secrets` are the store too. A path whose URL cannot be built is
|
|
231
|
+
* treated as the store: an error from it is sanitized, which costs nothing.
|
|
232
|
+
*/
|
|
233
|
+
#secretRoute(path) {
|
|
234
|
+
try {
|
|
235
|
+
const pathname = new URL(this.#url(path)).pathname;
|
|
236
|
+
const base = this.#base.pathname.replace(/\/+$/, '');
|
|
237
|
+
const decode = (p) => p
|
|
238
|
+
.split('/')
|
|
239
|
+
.map((seg) => {
|
|
240
|
+
try {
|
|
241
|
+
return decodeURIComponent(seg);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return seg;
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
.join('/');
|
|
248
|
+
if (pathname === base || pathname.startsWith(`${base}/`))
|
|
249
|
+
return secretRouteOf(decode(pathname.slice(base.length)));
|
|
250
|
+
// Above the API root: judge by the last segments, which is where the
|
|
251
|
+
// store's own two routes would be.
|
|
252
|
+
const parts = decode(pathname).toLowerCase().split('/').filter(Boolean);
|
|
253
|
+
if (parts.at(-1) === 'secrets')
|
|
254
|
+
return 'secrets';
|
|
255
|
+
if (parts.includes('secrets'))
|
|
256
|
+
return 'secrets/:id';
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return 'secrets/:id';
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The one boundary for the secret store. Every public operation runs inside
|
|
265
|
+
* it, so ANY exception a request to `secrets` or `secrets/:id` raises — a
|
|
266
|
+
* refusal, a redirect, a malformed or empty answer, a body read, a header
|
|
267
|
+
* check, a network failure, an argument that throws when it is read — leaves
|
|
268
|
+
* as the rebuilt error {@link sanitizeSecretError} makes. The request's value
|
|
269
|
+
* is read inside the sanitizer's own guard, never here.
|
|
270
|
+
*/
|
|
271
|
+
async #guard(method, path, opts, fn) {
|
|
272
|
+
const route = this.#secretRoute(path);
|
|
273
|
+
if (route === undefined)
|
|
274
|
+
return fn();
|
|
275
|
+
try {
|
|
276
|
+
return await fn();
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
throw sanitizeSecretError(err, method, route, () => opts?.body);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async #fetch(method, path, opts = {}, bounded = false) {
|
|
283
|
+
const headers = { ...this.#headers };
|
|
284
|
+
for (const [name, value] of Object.entries(opts.headers ?? {})) {
|
|
285
|
+
// Case-insensitively: a record keyed `x-mandala-mcp-service` beside the
|
|
286
|
+
// canonical spelling would be sent as both, joined into one value.
|
|
287
|
+
if (name.toLowerCase() !== SERVICE_HEADER.toLowerCase())
|
|
288
|
+
headers[name] = value;
|
|
289
|
+
}
|
|
290
|
+
if (this.#options.serviceSecret)
|
|
291
|
+
headers[SERVICE_HEADER] = this.#options.serviceSecret;
|
|
194
292
|
// Typed as what we actually build rather than as BodyInit, which @types/node
|
|
195
293
|
// does not put in the global scope.
|
|
196
294
|
let body;
|
|
@@ -207,6 +305,8 @@ export class Api {
|
|
|
207
305
|
body = JSON.stringify(opts.body);
|
|
208
306
|
}
|
|
209
307
|
const signal = opts.signal ?? this.#signal;
|
|
308
|
+
if (bounded && signal?.aborted)
|
|
309
|
+
throw cancellationError(method, path, 'before dispatch');
|
|
210
310
|
const requested = this.#url(path, opts.query);
|
|
211
311
|
const fetchRequest = platformFetch();
|
|
212
312
|
let resp;
|
|
@@ -227,7 +327,11 @@ export class Api {
|
|
|
227
327
|
redirect: 'manual',
|
|
228
328
|
dispatcher: PLATFORM_DISPATCHER,
|
|
229
329
|
};
|
|
230
|
-
resp =
|
|
330
|
+
resp = bounded
|
|
331
|
+
? await boundedWait(fetchRequest(requested, init), signal, (value) => {
|
|
332
|
+
void value.body?.cancel().catch(() => { });
|
|
333
|
+
})
|
|
334
|
+
: await fetchRequest(requested, init);
|
|
231
335
|
}
|
|
232
336
|
catch (cause) {
|
|
233
337
|
// Cancellation first, because it is not a connectivity failure and the
|
|
@@ -287,14 +391,26 @@ export class Api {
|
|
|
287
391
|
// one holds its undici connection open until the GC gets to it — and the
|
|
288
392
|
// whole point of this branch is a misconfigured base URL, which means
|
|
289
393
|
// EVERY request takes it.
|
|
290
|
-
|
|
394
|
+
if (bounded)
|
|
395
|
+
void resp.body?.cancel().catch(() => { });
|
|
396
|
+
else
|
|
397
|
+
await resp.body?.cancel().catch(() => { });
|
|
291
398
|
throw new RedirectError(`${method} /${path.replace(/^\/+/, '')} was redirected (HTTP ${resp.status}${to ? ` to ${to}` : ', with no Location header'}). This client does not follow redirects. Verify that MANDALA_BASE_URL names the API ` +
|
|
292
399
|
`root that serves this request; ${to
|
|
293
400
|
? 'the resource URL in Location is not itself a base URL to copy'
|
|
294
|
-
: 'the configured API root did not identify the resource directly'}. Retrying this unchanged gets the same answer.`, resp.status);
|
|
401
|
+
: 'the configured API root did not identify the resource directly'}. Retrying this unchanged gets the same answer.`, resp.status, undefined, undefined, responseMetadata(resp));
|
|
402
|
+
}
|
|
403
|
+
if (!resp.ok) {
|
|
404
|
+
// Reported before the body is read, so a transport holding its answer
|
|
405
|
+
// for this can refuse the HTTP request while the tool is still unwinding.
|
|
406
|
+
if (resp.status === 401 && bearerChallenged(resp.headers.get('www-authenticate'))) {
|
|
407
|
+
this.#options.onBearerRefused?.();
|
|
408
|
+
}
|
|
409
|
+
const err = await this.#error(resp, method, path, signal, bounded);
|
|
410
|
+
// A create-only upload's 409 with no usable reason is final, decided here
|
|
411
|
+
// rather than in the tool so an embedder's isTransient agrees (OPL-4994).
|
|
412
|
+
throw isCreateOnlyUpload(method, path, opts.query) ? createOnlyRefusal(err) : err;
|
|
295
413
|
}
|
|
296
|
-
if (!resp.ok)
|
|
297
|
-
throw await this.#error(resp, method, path, signal);
|
|
298
414
|
return resp;
|
|
299
415
|
}
|
|
300
416
|
/**
|
|
@@ -305,13 +421,18 @@ export class Api {
|
|
|
305
421
|
* that predates window actions" — and replacing them with a status line would
|
|
306
422
|
* throw away the only part of the response a model can do anything with.
|
|
307
423
|
*/
|
|
308
|
-
async #error(resp, method, path, signal) {
|
|
424
|
+
async #error(resp, method, path, signal, bounded = false) {
|
|
309
425
|
let body;
|
|
310
426
|
let message = `HTTP ${resp.status}`;
|
|
311
427
|
let text = '';
|
|
312
428
|
let truncated = false;
|
|
313
429
|
try {
|
|
314
|
-
({ text, truncated } = await readBody(method, path, signal, () =>
|
|
430
|
+
({ text, truncated } = await readBody(method, path, signal, () => bounded
|
|
431
|
+
? readComplete(resp, 8192, signal).then((bytes) => ({
|
|
432
|
+
text: new TextDecoder().decode(bytes),
|
|
433
|
+
truncated: false,
|
|
434
|
+
}))
|
|
435
|
+
: readTextAtMost(resp, MAX_ERROR_BODY_BYTES)));
|
|
315
436
|
}
|
|
316
437
|
catch (cause) {
|
|
317
438
|
// A response whose error body itself is broken still has a useful status.
|
|
@@ -320,7 +441,16 @@ export class Api {
|
|
|
320
441
|
if (cause instanceof CancelledError)
|
|
321
442
|
throw cause;
|
|
322
443
|
}
|
|
323
|
-
if (text) {
|
|
444
|
+
if (text && this.#secretRoute(path) !== undefined) {
|
|
445
|
+
// The secret store takes a value in its request, so its answers are the
|
|
446
|
+
// one place a platform echoing input would echo a secret. No response
|
|
447
|
+
// text is kept for them at all — not as the message, not as the body:
|
|
448
|
+
// only the one-word `reason`, when it is a word, and the status. A
|
|
449
|
+
// truncated copy of the text cannot be redacted reliably, so none is
|
|
450
|
+
// made (OPL-5026).
|
|
451
|
+
body = secretStoreRefusalBody(truncated ? undefined : text);
|
|
452
|
+
}
|
|
453
|
+
else if (text) {
|
|
324
454
|
try {
|
|
325
455
|
// A prefix is not JSON even when it happens to end at a syntactically
|
|
326
456
|
// valid boundary. Only trust a structured platform message after the
|
|
@@ -328,11 +458,7 @@ export class Api {
|
|
|
328
458
|
if (truncated)
|
|
329
459
|
throw new SyntaxError('truncated response body');
|
|
330
460
|
body = JSON.parse(text);
|
|
331
|
-
|
|
332
|
-
if (typeof err === 'string' && err)
|
|
333
|
-
message = err;
|
|
334
|
-
else
|
|
335
|
-
message = text.slice(0, 500);
|
|
461
|
+
message = platformSaid(body) ?? text.slice(0, 500);
|
|
336
462
|
}
|
|
337
463
|
catch {
|
|
338
464
|
message = text.slice(0, 500);
|
|
@@ -349,12 +475,19 @@ export class Api {
|
|
|
349
475
|
}
|
|
350
476
|
}
|
|
351
477
|
const delay = retryAfterMs(resp.headers.get('retry-after'));
|
|
478
|
+
const metadata = responseMetadata(resp);
|
|
352
479
|
// Content-Range describes the file length, independently of Retry-After.
|
|
353
480
|
if (resp.status === 416) {
|
|
354
481
|
const total = parseContentRange(resp.headers.get('content-range'))?.total;
|
|
355
|
-
return new RangeNotSatisfiableError(message, resp.status, body, total, delay
|
|
482
|
+
return new RangeNotSatisfiableError(message, resp.status, body, total, delay, {
|
|
483
|
+
...metadata,
|
|
484
|
+
method,
|
|
485
|
+
});
|
|
356
486
|
}
|
|
357
|
-
return errorForStatus(resp.status, message, body, delay
|
|
487
|
+
return errorForStatus(resp.status, message, body, delay, {
|
|
488
|
+
...metadata,
|
|
489
|
+
method,
|
|
490
|
+
});
|
|
358
491
|
}
|
|
359
492
|
/**
|
|
360
493
|
* A JSON body, or nothing, or a named failure.
|
|
@@ -379,7 +512,10 @@ export class Api {
|
|
|
379
512
|
return JSON.parse(text);
|
|
380
513
|
}
|
|
381
514
|
catch {
|
|
382
|
-
|
|
515
|
+
// Never the text for the secret store: see #error.
|
|
516
|
+
throw new MandalaError(this.#secretRoute(path) !== undefined
|
|
517
|
+
? `expected JSON from ${method} ${path}, got a body that is not JSON (not shown)`
|
|
518
|
+
: `expected JSON from ${method} ${path}, got: ${text.slice(0, 200)}`);
|
|
383
519
|
}
|
|
384
520
|
}
|
|
385
521
|
/**
|
|
@@ -396,6 +532,9 @@ export class Api {
|
|
|
396
532
|
* Routes where an empty body IS the answer use `send`.
|
|
397
533
|
*/
|
|
398
534
|
async json(method, path, opts = {}) {
|
|
535
|
+
return this.#guard(method, path, opts, () => this.#jsonRaw(method, path, opts));
|
|
536
|
+
}
|
|
537
|
+
async #jsonRaw(method, path, opts = {}) {
|
|
399
538
|
const resp = await this.#fetch(method, path, opts);
|
|
400
539
|
const body = await this.#decode(resp, method, path, opts.signal ?? this.#signal);
|
|
401
540
|
if (body === undefined || body === null) {
|
|
@@ -403,6 +542,75 @@ export class Api {
|
|
|
403
542
|
}
|
|
404
543
|
return body;
|
|
405
544
|
}
|
|
545
|
+
/** Existing exec decoding plus observed status, used only to confirm optional retention. */
|
|
546
|
+
async jsonWithStatus(method, path, opts = {}) {
|
|
547
|
+
return this.#guard(method, path, opts, () => this.#jsonWithStatusRaw(method, path, opts));
|
|
548
|
+
}
|
|
549
|
+
async #jsonWithStatusRaw(method, path, opts = {}) {
|
|
550
|
+
const resp = await this.#fetch(method, path, opts);
|
|
551
|
+
const value = await this.#decode(resp, method, path, opts.signal ?? this.#signal);
|
|
552
|
+
if (value === undefined || value === null)
|
|
553
|
+
throw new MandalaError('Expected an exec response');
|
|
554
|
+
return { status: resp.status, value };
|
|
555
|
+
}
|
|
556
|
+
/** Complete bounded responses for immutable retained protocols; never a successful prefix. */
|
|
557
|
+
async boundedBytes(method, path, limit, status, opts = {}) {
|
|
558
|
+
return this.#guard(method, path, opts, () => this.#boundedBytesRaw(method, path, limit, status, opts));
|
|
559
|
+
}
|
|
560
|
+
async #boundedBytesRaw(method, path, limit, status, opts = {}) {
|
|
561
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || limit > 64 * 1024 * 1024)
|
|
562
|
+
throw new MandalaError('Invalid bounded response limit');
|
|
563
|
+
const signal = opts.signal ?? this.#signal;
|
|
564
|
+
const resp = await this.#fetch(method, path, { ...opts, headers: { ...opts.headers, 'Accept-Encoding': 'identity' } }, true);
|
|
565
|
+
try {
|
|
566
|
+
if (resp.status !== status ||
|
|
567
|
+
resp.headers.has('Content-Range') ||
|
|
568
|
+
!['', 'identity'].includes(resp.headers.get('Content-Encoding') ?? ''))
|
|
569
|
+
throw new MandalaError('Unexpected retained response protocol');
|
|
570
|
+
const length = resp.headers.get('Content-Length');
|
|
571
|
+
if (length !== null &&
|
|
572
|
+
(!/^(0|[1-9][0-9]*)$/.test(length) ||
|
|
573
|
+
String(Number(length)) !== length ||
|
|
574
|
+
!Number.isSafeInteger(Number(length)) ||
|
|
575
|
+
Number(length) > limit))
|
|
576
|
+
throw new MandalaError('Invalid retained response length');
|
|
577
|
+
const bytes = await readBody(method, path, signal, () => readComplete(resp, limit, signal));
|
|
578
|
+
if (length !== null && Number(length) !== bytes.length)
|
|
579
|
+
throw new MandalaError('Incomplete retained response');
|
|
580
|
+
if (signal?.aborted)
|
|
581
|
+
throw cancellationError(method, path, 'after reading the platform response');
|
|
582
|
+
const headers = {};
|
|
583
|
+
for (const name of [
|
|
584
|
+
'content-type',
|
|
585
|
+
'content-length',
|
|
586
|
+
'x-result-offset',
|
|
587
|
+
'x-result-next-offset',
|
|
588
|
+
'x-result-eof',
|
|
589
|
+
]) {
|
|
590
|
+
const value = resp.headers.get(name);
|
|
591
|
+
if (value !== null)
|
|
592
|
+
headers[name] = value;
|
|
593
|
+
}
|
|
594
|
+
return { bytes, headers };
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
void resp.body?.cancel().catch(() => { });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async boundedJson(method, path, limit, status, opts = {}) {
|
|
601
|
+
return this.#guard(method, path, opts, () => this.#boundedJsonRaw(method, path, limit, status, opts));
|
|
602
|
+
}
|
|
603
|
+
async #boundedJsonRaw(method, path, limit, status, opts = {}) {
|
|
604
|
+
const response = await this.boundedBytes(method, path, limit, status, opts);
|
|
605
|
+
if (!/^application\/json(?:\s*;|$)/i.test(response.headers['content-type'] ?? ''))
|
|
606
|
+
throw new MandalaError('Expected retained JSON metadata');
|
|
607
|
+
try {
|
|
608
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(response.bytes));
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
throw new MandalaError('Invalid retained JSON metadata');
|
|
612
|
+
}
|
|
613
|
+
}
|
|
406
614
|
/**
|
|
407
615
|
* A request whose answer may legitimately be nothing.
|
|
408
616
|
*
|
|
@@ -411,6 +619,9 @@ export class Api {
|
|
|
411
619
|
* possibly-absent so a caller has to decide what to say when it is.
|
|
412
620
|
*/
|
|
413
621
|
async send(method, path, opts = {}) {
|
|
622
|
+
return this.#guard(method, path, opts, () => this.#sendRaw(method, path, opts));
|
|
623
|
+
}
|
|
624
|
+
async #sendRaw(method, path, opts = {}) {
|
|
414
625
|
const resp = await this.#fetch(method, path, opts);
|
|
415
626
|
return this.#decode(resp, method, path, opts.signal ?? this.#signal);
|
|
416
627
|
}
|
|
@@ -430,6 +641,9 @@ export class Api {
|
|
|
430
641
|
* count that means nothing at zero.
|
|
431
642
|
*/
|
|
432
643
|
async listing(path, opts = {}) {
|
|
644
|
+
return this.#guard('GET', path, opts, () => this.#listingRaw(path, opts));
|
|
645
|
+
}
|
|
646
|
+
async #listingRaw(path, opts = {}) {
|
|
433
647
|
const resp = await this.#fetch('GET', path, opts);
|
|
434
648
|
const short = resp.headers.get('X-GC-Incomplete');
|
|
435
649
|
return {
|
|
@@ -442,6 +656,9 @@ export class Api {
|
|
|
442
656
|
}
|
|
443
657
|
/** For the two routes whose body is not JSON: the screenshot and the download. */
|
|
444
658
|
async bytes(method, path, opts = {}, maxBytes) {
|
|
659
|
+
return this.#guard(method, path, opts, () => this.#bytesRaw(method, path, opts, maxBytes));
|
|
660
|
+
}
|
|
661
|
+
async #bytesRaw(method, path, opts = {}, maxBytes) {
|
|
445
662
|
const resp = await this.#fetch(method, path, opts);
|
|
446
663
|
const contentType = mediaType(resp.headers.get('content-type'));
|
|
447
664
|
const limit = typeof maxBytes === 'function' ? maxBytes(contentType) : maxBytes;
|
|
@@ -506,6 +723,77 @@ export class Api {
|
|
|
506
723
|
window,
|
|
507
724
|
};
|
|
508
725
|
}
|
|
726
|
+
// --- the account's secret store (OPL-4984) ------------------------------
|
|
727
|
+
/**
|
|
728
|
+
* The account's secret store, `GET|POST secrets` and `GET|PUT|DELETE
|
|
729
|
+
* secrets/:id`, as five typed calls.
|
|
730
|
+
*
|
|
731
|
+
* Typed, and decoded strictly, because a value goes IN here and the caller
|
|
732
|
+
* must be able to rely on nothing coming back: every answer is reduced to the
|
|
733
|
+
* documented fields before it is returned, so no answer can hand a value on.
|
|
734
|
+
* A malformed answer is a {@link MandalaError}; for a change it says the
|
|
735
|
+
* change may have been made. Bound to this client, so
|
|
736
|
+
* `api.with(signal).secrets` carries the signal.
|
|
737
|
+
*/
|
|
738
|
+
get secrets() {
|
|
739
|
+
// Each call runs wholly inside the boundary: its arguments are read, the
|
|
740
|
+
// request made and the answer decoded within one try, and anything thrown
|
|
741
|
+
// is rebuilt by the sanitizer — which reads the value under its own guard.
|
|
742
|
+
const guard = (method, route, value, fn) => (async () => {
|
|
743
|
+
try {
|
|
744
|
+
return await fn();
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
throw sanitizeSecretError(err, method, route, value);
|
|
748
|
+
}
|
|
749
|
+
})();
|
|
750
|
+
const malformed = () => new MalformedSecretAnswerError('malformed');
|
|
751
|
+
return {
|
|
752
|
+
list: (opts) => guard('GET', 'secrets', () => undefined, async () => {
|
|
753
|
+
const list = secretListOf(await this.json('GET', P.SECRETS, {
|
|
754
|
+
query: P.secretScopeQuery(opts?.workspaceId),
|
|
755
|
+
}));
|
|
756
|
+
if (!list)
|
|
757
|
+
throw malformed();
|
|
758
|
+
return list;
|
|
759
|
+
}),
|
|
760
|
+
create: (args) => guard('POST', 'secrets', () => args.value, async () => {
|
|
761
|
+
const body = {
|
|
762
|
+
name: args.name,
|
|
763
|
+
value: args.value,
|
|
764
|
+
...(args.workspaceId === undefined ? {} : { workspace_id: args.workspaceId }),
|
|
765
|
+
};
|
|
766
|
+
const secret = secretOf(await this.json('POST', P.SECRETS, { body }));
|
|
767
|
+
if (!secret)
|
|
768
|
+
throw malformed();
|
|
769
|
+
return secret;
|
|
770
|
+
}),
|
|
771
|
+
get: (id, opts) => guard('GET', 'secrets/:id', () => undefined, async () => {
|
|
772
|
+
const secret = secretOf(await this.json('GET', P.secret(id), {
|
|
773
|
+
query: P.secretScopeQuery(opts?.workspaceId),
|
|
774
|
+
}));
|
|
775
|
+
if (!secret)
|
|
776
|
+
throw malformed();
|
|
777
|
+
return secret;
|
|
778
|
+
}),
|
|
779
|
+
replace: (id, args) => guard('PUT', 'secrets/:id', () => args.value, async () => {
|
|
780
|
+
const body = {
|
|
781
|
+
value: args.value,
|
|
782
|
+
revision_id: args.revisionId,
|
|
783
|
+
...(args.workspaceId === undefined ? {} : { workspace_id: args.workspaceId }),
|
|
784
|
+
};
|
|
785
|
+
const secret = secretOf(await this.json('PUT', P.secret(id), { body }));
|
|
786
|
+
if (!secret)
|
|
787
|
+
throw malformed();
|
|
788
|
+
return secret;
|
|
789
|
+
}),
|
|
790
|
+
delete: (id, args) => guard('DELETE', 'secrets/:id', () => undefined, async () => {
|
|
791
|
+
await this.send('DELETE', P.secret(id), {
|
|
792
|
+
query: { revision_id: args.revisionId, ...P.secretScopeQuery(args.workspaceId) },
|
|
793
|
+
});
|
|
794
|
+
}),
|
|
795
|
+
};
|
|
796
|
+
}
|
|
509
797
|
/**
|
|
510
798
|
* The agent route, which answers with a stream of steps rather than a result.
|
|
511
799
|
*
|
|
@@ -514,6 +802,21 @@ export class Api {
|
|
|
514
802
|
* it is over is one the person watching cannot tell from a hang.
|
|
515
803
|
*/
|
|
516
804
|
async *sse(method, path, opts = {}) {
|
|
805
|
+
// The iterator runs inside the boundary too: an error while reading the
|
|
806
|
+
// stream is raised from here, not from the request.
|
|
807
|
+
const route = this.#secretRoute(path);
|
|
808
|
+
if (route === undefined) {
|
|
809
|
+
yield* this.#sseRaw(method, path, opts);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
yield* this.#sseRaw(method, path, opts);
|
|
814
|
+
}
|
|
815
|
+
catch (err) {
|
|
816
|
+
throw sanitizeSecretError(err, method, route, () => opts.body);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
async *#sseRaw(method, path, opts = {}) {
|
|
517
820
|
const resp = await this.#fetch(method, path, {
|
|
518
821
|
...opts,
|
|
519
822
|
headers: { ...opts.headers, Accept: 'text/event-stream' },
|
|
@@ -1208,4 +1511,72 @@ export function filenameFrom(disposition) {
|
|
|
1208
1511
|
return p.value;
|
|
1209
1512
|
return undefined;
|
|
1210
1513
|
}
|
|
1514
|
+
/** An abort releases the caller even if a peer ignores cancellation. Late headers are cancelled. */
|
|
1515
|
+
function boundedWait(work, signal, late) {
|
|
1516
|
+
return new Promise((resolve, reject) => {
|
|
1517
|
+
let settled = false;
|
|
1518
|
+
const abort = () => {
|
|
1519
|
+
if (settled)
|
|
1520
|
+
return;
|
|
1521
|
+
settled = true;
|
|
1522
|
+
signal?.removeEventListener('abort', abort);
|
|
1523
|
+
reject(new CancelledError('Retained operation cancelled; commitment may be unknown.'));
|
|
1524
|
+
};
|
|
1525
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
1526
|
+
if (signal?.aborted)
|
|
1527
|
+
abort();
|
|
1528
|
+
work.then((value) => {
|
|
1529
|
+
if (settled) {
|
|
1530
|
+
late?.(value);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
settled = true;
|
|
1534
|
+
signal?.removeEventListener('abort', abort);
|
|
1535
|
+
resolve(value);
|
|
1536
|
+
}, (error) => {
|
|
1537
|
+
if (settled)
|
|
1538
|
+
return;
|
|
1539
|
+
settled = true;
|
|
1540
|
+
signal?.removeEventListener('abort', abort);
|
|
1541
|
+
reject(error);
|
|
1542
|
+
});
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
async function readComplete(resp, limit, signal) {
|
|
1546
|
+
if (signal?.aborted)
|
|
1547
|
+
throw new CancelledError('Retained operation cancelled');
|
|
1548
|
+
if (!resp.body)
|
|
1549
|
+
return new Uint8Array();
|
|
1550
|
+
const reader = resp.body.getReader(), chunks = [];
|
|
1551
|
+
let size = 0;
|
|
1552
|
+
const cancel = () => {
|
|
1553
|
+
void reader.cancel().catch(() => { });
|
|
1554
|
+
};
|
|
1555
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
1556
|
+
try {
|
|
1557
|
+
for (;;) {
|
|
1558
|
+
const next = await boundedWait(reader.read(), signal);
|
|
1559
|
+
if (signal?.aborted)
|
|
1560
|
+
throw new CancelledError('Retained operation cancelled');
|
|
1561
|
+
if (next.done)
|
|
1562
|
+
break;
|
|
1563
|
+
if (next.value.byteLength > limit - size)
|
|
1564
|
+
throw new MandalaError('Retained response exceeds limit');
|
|
1565
|
+
chunks.push(next.value.slice());
|
|
1566
|
+
size += next.value.byteLength;
|
|
1567
|
+
}
|
|
1568
|
+
const bytes = new Uint8Array(size);
|
|
1569
|
+
let at = 0;
|
|
1570
|
+
for (const chunk of chunks) {
|
|
1571
|
+
bytes.set(chunk, at);
|
|
1572
|
+
at += chunk.length;
|
|
1573
|
+
}
|
|
1574
|
+
return bytes;
|
|
1575
|
+
}
|
|
1576
|
+
finally {
|
|
1577
|
+
signal?.removeEventListener('abort', cancel);
|
|
1578
|
+
cancel();
|
|
1579
|
+
reader.releaseLock();
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1211
1582
|
//# sourceMappingURL=api.js.map
|