@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.
- package/README.md +133 -2
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +39 -0
- package/dist/compute-session.d.ts +135 -0
- package/dist/compute-session.js +373 -0
- package/dist/default-base-url.generated.d.ts +5 -0
- package/dist/default-base-url.generated.js +5 -0
- package/dist/dispatch.d.ts +40 -0
- package/dist/dispatch.js +265 -0
- package/dist/embed.d.ts +39 -0
- package/dist/embed.js +51 -0
- package/dist/git-host.d.ts +16 -0
- package/dist/git-host.js +184 -0
- package/dist/json-operation-callability.d.ts +31 -0
- package/dist/json-operation-callability.js +57 -0
- package/dist/manifest.d.ts +531 -0
- package/dist/manifest.js +558 -0
- package/dist/permissions.d.ts +46 -0
- package/dist/permissions.js +106 -0
- package/dist/program.d.ts +129 -0
- package/dist/program.js +985 -0
- package/dist/request-timeout.d.ts +8 -0
- package/dist/request-timeout.js +33 -0
- package/dist/resolve.d.ts +53 -0
- package/dist/resolve.js +111 -0
- package/dist/run.d.ts +44 -0
- package/dist/run.js +81 -0
- package/dist/skills.d.ts +73 -0
- package/dist/skills.js +235 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +22 -0
- package/package.json +61 -4
|
@@ -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,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>;
|