@labelbox/horizon-cli 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,373 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import process from 'node:process';
5
+ import { z } from 'zod';
6
+ import { supportUrl } from './manifest.js';
7
+ import { gatedSummary, missingRequiredPermission, permissionDeniedMessage, permissionHelpNote, UNAVAILABLE_HELP_GROUP, } from './permissions.js';
8
+ import { resolveCommandAuth } from './resolve.js';
9
+ // ── bespoke `horizon computes open/tools` commands (hand-written, not spec-derived) ──
10
+ //
11
+ // `computes mint-access-session` stops at the redemption URL. Turning that into a
12
+ // usable session means issuing a second request purely to capture a `Set-Cookie`
13
+ // off a 302 — a header-shaped result the manifest-driven dispatch loop (JSON in,
14
+ // JSON out) has no way to express. Hence a hand-written command, same as `skills`.
15
+ //
16
+ // The mint→redeem flow, its origin binding, and its cookie parsing are a port of
17
+ // `proxyUrlFromRedemption` / `redeemComputeSession` in `tools/dx/src/commands/devbox.ts`.
18
+ // `tools/dx` is a dev tool and this is a published package, so the code cannot be
19
+ // shared; the safeguards are kept identical on purpose, and a change to either
20
+ // should be mirrored.
21
+ /** The only credential the compute edge's ingress accepts, set on redemption. */
22
+ const SESSION_COOKIE_NAME = '__Secure-compute_session';
23
+ /**
24
+ * The session cookie in a `Set-Cookie` header, anchored at the header's start and
25
+ * requiring at least one character of value.
26
+ *
27
+ * Anchoring is what `split(';')` parsing cannot do: it would also match the edge's
28
+ * *deletion* cookie (`__Secure-compute_session=; Max-Age=0`) and hand back a
29
+ * perfectly-formatted empty credential. Built from `SESSION_COOKIE_NAME` (which
30
+ * contains no regex metacharacters) so a rename cannot leave the two disagreeing.
31
+ */
32
+ const SESSION_COOKIE_PATTERN = new RegExp(`^${SESSION_COOKIE_NAME}=([^;\\r\\n]+)(?:;|$)`, 'u');
33
+ /** The permission `POST /computes/:id/access/sessions` requires — the one the
34
+ * mint call would 403 on. Declared here because a hand-written command has no
35
+ * manifest entry to read `requiredPermissions` from. */
36
+ const COMPUTES_ACCESS_PERMISSION = 'computes:access';
37
+ // Bound both hops for the same reason as every other fetch in this package
38
+ // (dispatch.ts, manifest.ts, permissions.ts, skills.ts): a server that accepts the
39
+ // connection but never answers would otherwise hang the CLI forever. It matters
40
+ // more here than elsewhere — the redemption URL is single-use, so a hang burns it
41
+ // permanently rather than merely stalling.
42
+ const SESSION_FETCH_TIMEOUT_MS = 30_000;
43
+ /** Mirrors `ComputeAccessSessionDto`; `mintAccessSession`'s 201 body. */
44
+ const AccessSessionSchema = z.object({
45
+ // Mirrors the DTO's own `z.string().url()`; `redemptionUrlExpiresAt` is an
46
+ // undecorated string there too, so it stays one here — the mirror has to stay
47
+ // truthful, and claiming a format the server never promised would be a lie the
48
+ // parser enforces.
49
+ redemptionUrl: z.url(),
50
+ redemptionUrlExpiresAt: z.string(),
51
+ });
52
+ export const SESSION_FORMATS = ['header-file', 'claude-code', 'json', 'cookie'];
53
+ /** The default, and the reason it is the default: every other format puts a
54
+ * bearer-equivalent credential somewhere durable. */
55
+ export const DEFAULT_SESSION_FORMAT = 'header-file';
56
+ /**
57
+ * Bind the redemption origin **before** anything is sent to it.
58
+ *
59
+ * The redemption URL comes back over the wire, and the next thing the CLI does is
60
+ * make a request to it — so it is untrusted input on the way in and a request
61
+ * target on the way out. Everything downstream (the MCP endpoint, the cookie's
62
+ * eventual destination) is derived from this origin, which makes a bad one a
63
+ * credential-exfiltration primitive rather than a cosmetic error.
64
+ *
65
+ * Port of `proxyUrlFromRedemption` (devbox.ts): TLS only, no userinfo (which would
66
+ * let `https://c-<id>@attacker.example/` read as the expected host), a hostname
67
+ * that *starts with* `c-<computeId>.` and is strictly longer than that prefix (so
68
+ * the compute the caller named is the compute the session is for, and a bare
69
+ * `c-<id>.` with no domain is refused), and the exact redemption pathname.
70
+ */
71
+ export function redemptionOrigin(redemptionUrl, computeId) {
72
+ let parsed;
73
+ try {
74
+ parsed = new URL(redemptionUrl);
75
+ }
76
+ catch {
77
+ throw new Error(`the server returned an unparseable redemption URL for compute ${computeId}`);
78
+ }
79
+ // `URL` normalises the hostname to lower case, so the expected prefix has to be
80
+ // normalised too. A UUID is case-insensitive and the schema accepts either, so
81
+ // an id typed or pasted in upper case mints fine and would then fail this check
82
+ // — after the single-use URL has already been spent.
83
+ const expectedPrefix = `c-${computeId.toLowerCase()}.`;
84
+ if (parsed.protocol !== 'https:' ||
85
+ parsed.username !== '' ||
86
+ parsed.password !== '' ||
87
+ !parsed.hostname.startsWith(expectedPrefix) ||
88
+ parsed.hostname.length <= expectedPrefix.length ||
89
+ parsed.pathname !== '/__redeem') {
90
+ throw new Error(`the server returned an invalid redemption origin for compute ${computeId} — ` +
91
+ 'refusing to send the redemption request.');
92
+ }
93
+ return `https://${parsed.host}`;
94
+ }
95
+ /** `POST /v1/computes/:computeId/access/sessions` — mints a short-lived,
96
+ * single-use redemption URL for a running compute the caller owns. */
97
+ export async function mintAccessSession(args) {
98
+ const url = supportUrl(args.baseUrl, `/computes/${encodeURIComponent(args.computeId)}/access/sessions`);
99
+ const res = await fetch(url, {
100
+ method: 'POST',
101
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
102
+ headers: { Authorization: `Bearer ${args.apiKey}` },
103
+ signal: AbortSignal.timeout(SESSION_FETCH_TIMEOUT_MS),
104
+ });
105
+ if (!res.ok) {
106
+ const text = await res.text().catch(() => '');
107
+ throw new Error(`could not mint an access session for compute ${args.computeId} — HTTP ${res.status} ${text.slice(0, 200)}`);
108
+ }
109
+ const parsed = AccessSessionSchema.safeParse(await res.json());
110
+ if (!parsed.success) {
111
+ throw new Error(`unexpected response shape from ${url}`);
112
+ }
113
+ return parsed.data;
114
+ }
115
+ /** Redeem a minted URL into the edge session cookie.
116
+ *
117
+ * `computeId` is not decoration: the URL is validated against it *before* the
118
+ * request goes out (see `redemptionOrigin`), because sending it is the act that
119
+ * would leak.
120
+ *
121
+ * `redirect: 'manual'` is load-bearing: the cookie rides on the 302 itself, and
122
+ * following the redirect would consume the only header this request exists to
123
+ * read. The URL is single-use and TTL-bounded (60s upstream), so a failure here
124
+ * is not retryable with the same URL — mint a fresh one. */
125
+ export async function redeemSession(redemptionUrl, computeId) {
126
+ redemptionOrigin(redemptionUrl, computeId);
127
+ const res = await fetch(redemptionUrl, {
128
+ redirect: 'manual',
129
+ signal: AbortSignal.timeout(SESSION_FETCH_TIMEOUT_MS),
130
+ });
131
+ // A 4xx/5xx is a refusal, not a cookie-less success. Reported by its status,
132
+ // because "expired, mint another" is the wrong advice for a 403 and sends the
133
+ // caller into a retry loop against a permission they don't have.
134
+ if (res.status < 200 || res.status >= 400) {
135
+ throw new Error(`redeeming the access session for compute ${computeId} failed — HTTP ${res.status}.`);
136
+ }
137
+ const value = res.headers
138
+ .getSetCookie()
139
+ .map((header) => SESSION_COOKIE_PATTERN.exec(header)?.[1])
140
+ .find((match) => match !== undefined);
141
+ if (value === undefined) {
142
+ throw new Error(`redemption returned HTTP ${res.status} but no session cookie. The URL is single-use ` +
143
+ 'and expires within a minute of minting; mint a new one and redeem it immediately.');
144
+ }
145
+ return `${SESSION_COOKIE_NAME}=${value}`;
146
+ }
147
+ /** Mint and redeem in one step, returning everything an MCP client needs. */
148
+ export async function openComputeSession(args) {
149
+ const minted = await mintAccessSession(args);
150
+ // Bound before the redemption request is sent, and reused afterwards so the
151
+ // origin the cookie was fetched from is exactly the origin it will be sent to.
152
+ const origin = redemptionOrigin(minted.redemptionUrl, args.computeId);
153
+ const cookie = await redeemSession(minted.redemptionUrl, args.computeId);
154
+ return {
155
+ origin,
156
+ mcpEndpoint: `${origin}/mcp/`,
157
+ toolsEndpoint: `${origin}/mcp-health/tools`,
158
+ cookie,
159
+ };
160
+ }
161
+ /** One tool as the world's MCP server reports it on `/health/tools`.
162
+ *
163
+ * Mirrors `MCPToolHealth` in `worldsim_platform/mcp/server.py`. Only the fields the
164
+ * grouped view reads are modelled, and the object is *loose* on purpose: the world
165
+ * ships on its own cadence, so a tool gaining a field must neither break enumeration
166
+ * nor silently vanish. A stripping object would drop `method`, `path` and
167
+ * `example_arguments` — exactly the call-shape fields `--json` exists to carry. */
168
+ const WorldToolSchema = z.looseObject({
169
+ service: z.string(),
170
+ name: z.string(),
171
+ description: z.string().nullish(),
172
+ });
173
+ // `tools` alone is the shape guard — it is what rejects `{ nope: true }`. The
174
+ // server also sends `total_tools`, deliberately not required here: it is never
175
+ // read, and requiring it would let a rename upstream break enumeration for a
176
+ // field nothing depends on.
177
+ const WorldToolsSchema = z.object({ tools: z.array(WorldToolSchema) });
178
+ /** Every tool the world currently serves.
179
+ *
180
+ * This is the post-allowlist set: the MCP server filters by `WORLDSIM_MCP_TOOLS_FILE`
181
+ * before registering, so it answers what an agent in this world can actually call,
182
+ * not what the bake could expose. The bake's full catalogue is a different question,
183
+ * answered by `run-config-versions discover-mcp-tools`.
184
+ *
185
+ * Plain JSON over the same session cookie as `/mcp/` — no MCP handshake, so no
186
+ * `initialize` round-trip and no JSON-RPC pagination to unroll. */
187
+ export async function fetchWorldTools(session) {
188
+ const res = await fetch(session.toolsEndpoint, {
189
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
190
+ headers: { Cookie: session.cookie },
191
+ signal: AbortSignal.timeout(SESSION_FETCH_TIMEOUT_MS),
192
+ });
193
+ if (!res.ok) {
194
+ throw new Error(`could not list tools at ${session.toolsEndpoint} — HTTP ${res.status}. A running ` +
195
+ 'compute serves this only once its MCP service is up; check `computes get` for status.');
196
+ }
197
+ const parsed = WorldToolsSchema.safeParse(await res.json());
198
+ if (!parsed.success) {
199
+ throw new Error(`unexpected tool-listing shape from ${session.toolsEndpoint}`);
200
+ }
201
+ return parsed.data.tools;
202
+ }
203
+ /** Group by service, because a world's tools are namespaced by the service that
204
+ * backs them (`gitea.create_user`) and a flat list of 200 hides that structure. */
205
+ export function formatWorldTools(tools) {
206
+ if (tools.length === 0)
207
+ return 'This world serves no MCP tools.\n';
208
+ const byService = new Map();
209
+ for (const tool of tools) {
210
+ const existing = byService.get(tool.service);
211
+ if (existing === undefined)
212
+ byService.set(tool.service, [tool]);
213
+ else
214
+ existing.push(tool);
215
+ }
216
+ const services = [...byService.entries()].sort(([a], [b]) => a.localeCompare(b));
217
+ const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
218
+ const lines = [
219
+ `${plural(tools.length, 'tool')} across ${plural(services.length, 'service')}\n`,
220
+ ];
221
+ for (const [service, serviceTools] of services) {
222
+ lines.push(`${service} (${serviceTools.length})`);
223
+ for (const tool of [...serviceTools].sort((a, b) => a.name.localeCompare(b.name))) {
224
+ const description = tool.description?.split('\n')[0]?.trim();
225
+ lines.push(` ${tool.name}${description ? ` — ${description}` : ''}`);
226
+ }
227
+ lines.push('');
228
+ }
229
+ return `${lines.join('\n').trimEnd()}\n`;
230
+ }
231
+ /** Single-quote for a POSIX shell, closing the quote around each embedded quote. */
232
+ function shellQuote(value) {
233
+ return `'${value.replace(/'/gu, "'\\''")}'`;
234
+ }
235
+ /**
236
+ * The MCP server name a compute registers under when `--name` isn't given.
237
+ *
238
+ * A compute is a generic remote machine — `tools/dx devbox` runs devboxes on the
239
+ * same primitive — so the name is derived from the compute rather than from any
240
+ * one workload that happens to run on it. Derived rather than fixed so two open
241
+ * computes don't collide under one name in the client's config.
242
+ */
243
+ export function defaultServerName(computeId) {
244
+ return `compute-${computeId.replace(/[^A-Za-z0-9._-]/gu, '-')}`;
245
+ }
246
+ /** MCP client configs key servers by this name; keep it to what every client and
247
+ * the shell agree is one bare word. */
248
+ const SERVER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
249
+ export function parseServerName(value) {
250
+ if (!SERVER_NAME_PATTERN.test(value)) {
251
+ throw new Error(`invalid --name "${value}" — use letters, digits, ".", "_" or "-", starting with a letter or digit`);
252
+ }
253
+ return value;
254
+ }
255
+ /**
256
+ * Write the `Cookie:` request header to a file only the current user can read,
257
+ * and return its path.
258
+ *
259
+ * In its own `mkdtempSync` directory (mode 0700) rather than a fixed, guessable
260
+ * path under `tmpdir()`, and with the `wx` flag, which refuses to follow or
261
+ * overwrite an existing path — the same shape `git-host.ts` uses for its askpass
262
+ * helper, and for the same local-attacker-pre-plants-a-symlink reason. Unlike that
263
+ * one, this file *is* the secret, hence 0600.
264
+ */
265
+ export function writeSessionHeaderFile(session) {
266
+ const dir = mkdtempSync(join(tmpdir(), 'horizon-compute-session-'));
267
+ const path = join(dir, 'headers.txt');
268
+ writeFileSync(path, `Cookie: ${session.cookie}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
269
+ return path;
270
+ }
271
+ // Exhaustive by construction: a new SessionFormat that isn't handled fails to
272
+ // typecheck here rather than falling through to a default branch at runtime.
273
+ const FORMATTERS = {
274
+ // The default. The cookie is bearer-equivalent, so it goes to a 0600 file and
275
+ // the command interpolates it at run time: nothing here reaches `~/.zsh_history`
276
+ // or a `ps` listing, which is what every other format cannot avoid. Mirrors
277
+ // devbox's `--http-headers-file`, whose test asserts argv never carries a cookie.
278
+ 'header-file': ({ session, serverName }) => {
279
+ const path = writeSessionHeaderFile(session);
280
+ return (`# Session header written to ${path} (mode 0600). Delete it when you're done.\n` +
281
+ `claude mcp add --transport http ${serverName} ${shellQuote(session.mcpEndpoint)} ` +
282
+ `--header "$(cat ${shellQuote(path)})"\n` +
283
+ `# List every tool this world serves:\n` +
284
+ `# curl -sS -H "$(cat ${shellQuote(path)})" ${shellQuote(session.toolsEndpoint)}\n`);
285
+ },
286
+ 'claude-code': ({ session, serverName }) => `claude mcp add --transport http ${serverName} ${shellQuote(session.mcpEndpoint)} --header ${shellQuote(`Cookie: ${session.cookie}`)}\n`,
287
+ json: ({ session, serverName }) => `${JSON.stringify({
288
+ mcpServers: {
289
+ [serverName]: {
290
+ type: 'http',
291
+ url: session.mcpEndpoint,
292
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
293
+ headers: { Cookie: session.cookie },
294
+ },
295
+ },
296
+ }, null, 2)}\n`,
297
+ cookie: ({ session }) => `${session.cookie}\n`,
298
+ };
299
+ export function formatSession(session, format, serverName) {
300
+ return FORMATTERS[format]({ session, serverName });
301
+ }
302
+ /** Attach `open` and `tools` to the manifest-built `computes` group.
303
+ *
304
+ * Registered after the operation loop, so the group already exists — unless the
305
+ * backend serves no compute operations at all, in which case there is nothing to
306
+ * attach to and nothing to offer. A name collision means the backend grew its own
307
+ * `computes open` or `computes tools`; fail loudly rather than let commander silently shadow one,
308
+ * mirroring `claim()` in program.ts.
309
+ *
310
+ * Returns the `computes` group and number of gated commands when they were
311
+ * registered in their gated form, so the caller can fold them into that group's
312
+ * "N commands require permissions" banner; `undefined` otherwise. */
313
+ export function addComputeSessionCommands(program, helpGroup, granted) {
314
+ const computes = program.commands.find((command) => command.name() === 'computes');
315
+ if (computes === undefined)
316
+ return undefined;
317
+ for (const name of ['open', 'tools']) {
318
+ if (computes.commands.some((command) => command.name() === name)) {
319
+ throw new Error(`the backend spec now defines a "computes ${name}" operation, which collides with the ` +
320
+ 'hand-written one in compute-session.ts — rename one.');
321
+ }
322
+ }
323
+ // The same gate the manifest loop applies to `computes mint-access-session`,
324
+ // which both commands' first hop *is*. Without it they advertise themselves as
325
+ // runnable and then die on a raw HTTP 403 from the mint call.
326
+ const missing = missingRequiredPermission(granted, COMPUTES_ACCESS_PERMISSION);
327
+ const open = computes
328
+ .command('open <computeId>')
329
+ .helpGroup(missing === undefined ? helpGroup : UNAVAILABLE_HELP_GROUP)
330
+ .description(gatedSummary("Open an MCP session against a running compute's HTTP surface", missing))
331
+ .addHelpText('after', '\nMints an access session and redeems it, printing connection details for an MCP client.\n' +
332
+ 'The session is bearer-equivalent and expires after roughly an hour; re-run to renew.\n')
333
+ .option(`--format <${SESSION_FORMATS.join('|')}>`, 'Output shape: a command reading the header from a 0600 file (default), an inline ' +
334
+ 'claude-code command, an mcpServers config, or the bare cookie', DEFAULT_SESSION_FORMAT)
335
+ // No commander default: the default depends on the `<computeId>` argument,
336
+ // which is only known at action time, so it is applied there instead.
337
+ .option('--name <name>', 'MCP server name to register the compute under (default: compute-<computeId>)');
338
+ if (missing !== undefined)
339
+ open.addHelpText('after', permissionHelpNote(missing));
340
+ open.action(async (computeId, opts) => {
341
+ if (missing !== undefined)
342
+ throw new Error(permissionDeniedMessage(missing));
343
+ const format = SESSION_FORMATS.find((candidate) => candidate === opts.format);
344
+ if (format === undefined) {
345
+ throw new Error(`unknown --format "${opts.format}" — expected one of ${SESSION_FORMATS.join(', ')}`);
346
+ }
347
+ const serverName = opts.name === undefined ? defaultServerName(computeId) : parseServerName(opts.name);
348
+ const { apiKey, baseUrl } = resolveCommandAuth(program);
349
+ const session = await openComputeSession({ apiKey, baseUrl, computeId });
350
+ process.stdout.write(formatSession(session, format, serverName));
351
+ });
352
+ const tools = computes
353
+ .command('tools <computeId>')
354
+ .helpGroup(missing === undefined ? helpGroup : UNAVAILABLE_HELP_GROUP)
355
+ .description(gatedSummary('List every MCP tool a running compute serves', missing))
356
+ .addHelpText('after', '\nOpens a session and enumerates the tools the world currently serves, grouped by\n' +
357
+ 'service. This is the post-allowlist set — what an agent in this world can actually\n' +
358
+ 'call. Use --json to pipe it.\n')
359
+ .option('--json', 'Emit the raw tool array instead of the grouped summary');
360
+ if (missing !== undefined)
361
+ tools.addHelpText('after', permissionHelpNote(missing));
362
+ tools.action(async (computeId, opts) => {
363
+ if (missing !== undefined)
364
+ throw new Error(permissionDeniedMessage(missing));
365
+ const { apiKey, baseUrl } = resolveCommandAuth(program);
366
+ const session = await openComputeSession({ apiKey, baseUrl, computeId });
367
+ const worldTools = await fetchWorldTools(session);
368
+ process.stdout.write(opts.json === true
369
+ ? `${JSON.stringify(worldTools, null, 2)}\n`
370
+ : formatWorldTools(worldTools));
371
+ });
372
+ return missing === undefined ? undefined : { parent: computes, gatedCount: 2 };
373
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * AUTO-GENERATED by 'yarn generate cli:manifest'.
3
+ * Do not edit manually — this committed projection only bootstraps the live manifest fetch.
4
+ */
5
+ export declare const DEFAULT_BASE_URL = "https://api.horizon.labelbox.com";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * AUTO-GENERATED by 'yarn generate cli:manifest'.
3
+ * Do not edit manually — this committed projection only bootstraps the live manifest fetch.
4
+ */
5
+ export const DEFAULT_BASE_URL = 'https://api.horizon.labelbox.com';
@@ -0,0 +1,40 @@
1
+ import { type ManifestOperation } from './manifest.js';
2
+ export interface DispatchContext {
3
+ baseUrl: string;
4
+ apiKey: string;
5
+ /** Permit binary multipart fields to read local paths. Terminal CLI only. */
6
+ allowFileInputs?: boolean;
7
+ /**
8
+ * Scope headers, sent only when set.
9
+ *
10
+ * Some operations require a scope header the body cannot carry: `createCompute`
11
+ * is `@AllowE2E(requireEnvironmentScope)`, which reads
12
+ * `X-Environment-External-Id` and 403s without it — the `environmentId` in the
13
+ * body is the target, not the caller's scope, and does not satisfy it.
14
+ *
15
+ * Sent only when supplied because the requirement is per-operation and has a
16
+ * negative form: `rejectEnvironmentScope` 403s an operation that receives the
17
+ * header. Always attaching one would trade this failure for its mirror image.
18
+ */
19
+ organizationExternalId?: string;
20
+ environmentExternalId?: string;
21
+ }
22
+ /** The generic operation result; no HTTP semantics are discarded at dispatch. */
23
+ export interface DispatchResponse {
24
+ readonly data: unknown;
25
+ readonly status: number;
26
+ /** Present response headers declared for this status, under their contract names. */
27
+ readonly headers: Readonly<Record<string, string>>;
28
+ }
29
+ /** Build the full request URL: base + path (params substituted) + query string. */
30
+ export declare function buildUrl(op: ManifestOperation, params: Record<string, unknown>, baseUrl: string): string;
31
+ /**
32
+ * Build and send the request for one operation, returning its status, declared
33
+ * headers, and parsed data without collapsing HTTP semantics. Conditional 304 is
34
+ * successful only when the generated contract declares it. Binary bodies remain
35
+ * bytes; empty non-JSON text remains an empty string. On an undeclared response the
36
+ * parsed error body is thrown (so `formatError` prints the server's JSON detail);
37
+ * `fetch` rejecting for an unreachable server propagates and is mapped to the
38
+ * standard "could not be reached" message by `formatError`.
39
+ */
40
+ export declare function dispatchOperation(op: ManifestOperation, params: Record<string, unknown>, ctx: DispatchContext): Promise<DispatchResponse>;