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.
Files changed (128) hide show
  1. package/README.md +558 -29
  2. package/dist/api.d.ts +59 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +404 -33
  5. package/dist/api.js.map +1 -1
  6. package/dist/artifacts.d.ts +63 -0
  7. package/dist/artifacts.d.ts.map +1 -0
  8. package/dist/artifacts.js +81 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/cli.d.ts +4 -0
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +52 -21
  13. package/dist/cli.js.map +1 -1
  14. package/dist/credentials.d.ts +33 -0
  15. package/dist/credentials.d.ts.map +1 -0
  16. package/dist/credentials.js +395 -0
  17. package/dist/credentials.js.map +1 -0
  18. package/dist/errors.d.ts +122 -11
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js +237 -41
  21. package/dist/errors.js.map +1 -1
  22. package/dist/executions.d.ts +43 -0
  23. package/dist/executions.d.ts.map +1 -0
  24. package/dist/executions.js +162 -0
  25. package/dist/executions.js.map +1 -0
  26. package/dist/format.d.ts +33 -8
  27. package/dist/format.d.ts.map +1 -1
  28. package/dist/format.js +107 -12
  29. package/dist/format.js.map +1 -1
  30. package/dist/http.d.ts +42 -0
  31. package/dist/http.d.ts.map +1 -1
  32. package/dist/http.js +383 -3
  33. package/dist/http.js.map +1 -1
  34. package/dist/index.d.ts +5 -4
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +3 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/paths.d.ts +50 -2
  39. package/dist/paths.d.ts.map +1 -1
  40. package/dist/paths.js +65 -5
  41. package/dist/paths.js.map +1 -1
  42. package/dist/results.d.ts +97 -0
  43. package/dist/results.d.ts.map +1 -0
  44. package/dist/results.js +263 -0
  45. package/dist/results.js.map +1 -0
  46. package/dist/secret-errors.d.ts +59 -0
  47. package/dist/secret-errors.d.ts.map +1 -0
  48. package/dist/secret-errors.js +199 -0
  49. package/dist/secret-errors.js.map +1 -0
  50. package/dist/secret-store.d.ts +115 -0
  51. package/dist/secret-store.d.ts.map +1 -0
  52. package/dist/secret-store.js +105 -0
  53. package/dist/secret-store.js.map +1 -0
  54. package/dist/server.d.ts +3 -2
  55. package/dist/server.d.ts.map +1 -1
  56. package/dist/server.js +60 -9
  57. package/dist/server.js.map +1 -1
  58. package/dist/session.d.ts +6 -1
  59. package/dist/session.d.ts.map +1 -1
  60. package/dist/session.js +1 -1
  61. package/dist/session.js.map +1 -1
  62. package/dist/stdio.d.ts +5 -1
  63. package/dist/stdio.d.ts.map +1 -1
  64. package/dist/stdio.js +10 -4
  65. package/dist/stdio.js.map +1 -1
  66. package/dist/tool-filters.d.ts +38 -0
  67. package/dist/tool-filters.d.ts.map +1 -0
  68. package/dist/tool-filters.js +137 -0
  69. package/dist/tool-filters.js.map +1 -0
  70. package/dist/tools/account.d.ts +3 -0
  71. package/dist/tools/account.d.ts.map +1 -0
  72. package/dist/tools/account.js +122 -0
  73. package/dist/tools/account.js.map +1 -0
  74. package/dist/tools/activities.d.ts +3 -0
  75. package/dist/tools/activities.d.ts.map +1 -0
  76. package/dist/tools/activities.js +140 -0
  77. package/dist/tools/activities.js.map +1 -0
  78. package/dist/tools/agent.d.ts.map +1 -1
  79. package/dist/tools/agent.js +21 -9
  80. package/dist/tools/agent.js.map +1 -1
  81. package/dist/tools/artifacts.d.ts +3 -0
  82. package/dist/tools/artifacts.d.ts.map +1 -0
  83. package/dist/tools/artifacts.js +97 -0
  84. package/dist/tools/artifacts.js.map +1 -0
  85. package/dist/tools/chat.d.ts +6 -0
  86. package/dist/tools/chat.d.ts.map +1 -0
  87. package/dist/tools/chat.js +192 -0
  88. package/dist/tools/chat.js.map +1 -0
  89. package/dist/tools/computers.d.ts.map +1 -1
  90. package/dist/tools/computers.js +28 -16
  91. package/dist/tools/computers.js.map +1 -1
  92. package/dist/tools/directory.d.ts +13 -0
  93. package/dist/tools/directory.d.ts.map +1 -0
  94. package/dist/tools/directory.js +88 -0
  95. package/dist/tools/directory.js.map +1 -0
  96. package/dist/tools/executions.d.ts +3 -0
  97. package/dist/tools/executions.d.ts.map +1 -0
  98. package/dist/tools/executions.js +87 -0
  99. package/dist/tools/executions.js.map +1 -0
  100. package/dist/tools/guest.d.ts.map +1 -1
  101. package/dist/tools/guest.js +184 -35
  102. package/dist/tools/guest.js.map +1 -1
  103. package/dist/tools/input.d.ts +2 -0
  104. package/dist/tools/input.d.ts.map +1 -1
  105. package/dist/tools/input.js +31 -5
  106. package/dist/tools/input.js.map +1 -1
  107. package/dist/tools/results.d.ts +30 -0
  108. package/dist/tools/results.d.ts.map +1 -0
  109. package/dist/tools/results.js +106 -0
  110. package/dist/tools/results.js.map +1 -0
  111. package/dist/tools/secrets.d.ts +78 -0
  112. package/dist/tools/secrets.d.ts.map +1 -0
  113. package/dist/tools/secrets.js +448 -0
  114. package/dist/tools/secrets.js.map +1 -0
  115. package/dist/tools/signals.d.ts +3 -0
  116. package/dist/tools/signals.d.ts.map +1 -0
  117. package/dist/tools/signals.js +116 -0
  118. package/dist/tools/signals.js.map +1 -0
  119. package/dist/tools/snapshots.d.ts.map +1 -1
  120. package/dist/tools/snapshots.js +52 -10
  121. package/dist/tools/snapshots.js.map +1 -1
  122. package/dist/tools/ssh.d.ts +3 -0
  123. package/dist/tools/ssh.d.ts.map +1 -0
  124. package/dist/tools/ssh.js +186 -0
  125. package/dist/tools/ssh.js.map +1 -0
  126. package/dist/tools/webhooks.js +1 -1
  127. package/dist/tools/webhooks.js.map +1 -1
  128. 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
- constructor(apiKey, baseUrl = DEFAULT_BASE_URL, signal) {
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
- async #fetch(method, path, opts = {}) {
193
- const headers = { ...this.#headers, ...opts.headers };
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 = await fetchRequest(requested, init);
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
- await resp.body?.cancel().catch(() => { });
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, () => readTextAtMost(resp, MAX_ERROR_BODY_BYTES)));
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
- const err = body?.error;
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
- throw new MandalaError(`expected JSON from ${method} ${path}, got: ${text.slice(0, 200)}`);
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