@cloudgrid-io/mcp 0.20.6 → 0.20.8

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 (33) hide show
  1. package/README.md +40 -31
  2. package/package.json +2 -1
  3. package/src/corpus/capability-map.md +21 -13
  4. package/src/corpus/cloudgrid-yaml.md +5 -3
  5. package/src/corpus/examples/ai-app/index.md +4 -3
  6. package/src/corpus/examples/docs-app/index.md +113 -0
  7. package/src/corpus/templates/ai-app/README.md +4 -4
  8. package/src/corpus/templates/ai-app/index.md +4 -4
  9. package/src/corpus/templates/ai-knowledge-base/AGENTS.md +17 -17
  10. package/src/corpus/templates/ai-knowledge-base/README.md +5 -4
  11. package/src/corpus/templates/ai-knowledge-base/cloudgrid.yaml +5 -4
  12. package/src/corpus/templates/ai-knowledge-base/index.md +15 -11
  13. package/src/corpus/templates/semantic-search/AGENTS.md +9 -9
  14. package/src/corpus/templates/semantic-search/README.md +4 -3
  15. package/src/corpus/templates/semantic-search/cloudgrid.yaml +6 -5
  16. package/src/corpus/templates/semantic-search/index.md +7 -4
  17. package/src/corpus/templates/simple-semantic-search/README.md +20 -0
  18. package/src/corpus/templates/simple-semantic-search/cloudgrid.yaml +235 -0
  19. package/src/corpus/templates/simple-semantic-search/index.md +353 -0
  20. package/src/corpus/templates/simple-semantic-search/services/web/package.json +15 -0
  21. package/src/corpus/templates/simple-semantic-search/services/web/src/document.txt +40 -0
  22. package/src/corpus/templates/simple-semantic-search/services/web/src/index.js +225 -0
  23. package/src/corpus/workflows/ai-app.md +5 -4
  24. package/src/corpus/workflows/ai-knowledge-base.md +17 -14
  25. package/src/corpus/workflows/semantic-search.md +9 -7
  26. package/src/corpus/workflows/simple-semantic-search.md +64 -0
  27. package/src/playbook.js +135 -0
  28. package/src/tools/cli.js +446 -0
  29. package/src/tools/constants.js +106 -0
  30. package/src/tools/deploy.js +1677 -0
  31. package/src/tools/register.js +1165 -0
  32. package/src/tools/util.js +17 -0
  33. package/src/tools.js +30 -3470
@@ -0,0 +1,1677 @@
1
+ // Direct-API tool internals: grid_deploy (runPlug), grid, claim, report, fork,
2
+ // download, visibility, and source retrieval.
3
+ // Extracted verbatim from src/tools.js (refactor: split tools.js into modules).
4
+
5
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
6
+ import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
7
+ import { basename, dirname, resolve, join, sep } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import {
10
+ API_BASE,
11
+ MCP_VERSION,
12
+ ANON_HTML_MAX_BYTES,
13
+ AUTHED_HTML_MAX_BYTES,
14
+ CONSOLE_URL,
15
+ VISIBILITY_LABELS,
16
+ APPS_WIDGETS_ENABLED,
17
+ GRID_PICKER_URI,
18
+ PLUG_UPLOAD_TIMEOUT_MS,
19
+ } from "./constants.js";
20
+ import { okResult } from "./util.js";
21
+ import { runCloudgrid } from "./cli.js";
22
+
23
+ // ── Org listing (bearer-authed, web edition) ──────────────────────────────────
24
+ // Fetches the signed-in user's orgs via GET /api/v2/orgs. The JWT does not
25
+ // carry orgs (claims: sub, email, name, iat, exp), so the API is the canonical
26
+ // source. Returns [{slug, name, role, render_ready}].
27
+ export async function fetchUserOrgs(token) {
28
+ try {
29
+ const res = await fetch(`${API_BASE}/api/v2/orgs`, {
30
+ headers: { Authorization: `Bearer ${token}` },
31
+ });
32
+ if (!res.ok) return [];
33
+ const data = await res.json();
34
+ // 0.8.0: read the grid-native `data.grids` (dual-emitted alongside the legacy
35
+ // `data.orgs`, same array/order). Fall back to `data.orgs`/bare-array during soak.
36
+ const grids = Array.isArray(data?.grids)
37
+ ? data.grids
38
+ : Array.isArray(data?.orgs)
39
+ ? data.orgs
40
+ : Array.isArray(data)
41
+ ? data
42
+ : [];
43
+ return grids.map((o) => ({
44
+ slug: o.slug ?? "",
45
+ name: o.name ?? o.slug ?? "",
46
+ role: o.role ?? "member",
47
+ render_ready: o.render_ready ?? true, // default true for older APIs
48
+ }));
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ // ── Shared grid disambiguation (grid_deploy) ──────────────────
55
+ // The stateless "which grid?" ask on an authed create. Given the caller's token
56
+ // and a supplied grid, it decides:
57
+ // - supplied grid matches a membership → { proceed: true, grid }
58
+ // - >1 grid and none supplied → { picker } (a ready-to-return result)
59
+ // - exactly one grid → { single: annotatedOrg } — the caller
60
+ // decides how to treat a not-ready single grid (drop blocks; plug warns)
61
+ // - no orgs / listing failed → { proceed: true } (fall through)
62
+ // User-facing text says "grid" (Gilad's org→grid rename); the structured payload
63
+ // carries `needs_grid` AND the `needs_org`/`orgs`/`org`-slug fields the existing
64
+ // org-picker web widget reads, so the web card keeps working. Stateless — no
65
+ // dependence on prior-call state (ChatGPT Apps SDK reconnects every call).
66
+ export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, deps = {}) {
67
+ const listGrids = deps.fetchUserOrgs || fetchUserOrgs;
68
+ const grids = await listGrids(token);
69
+ const activeGrid = await ctx.getActiveGrid();
70
+ const matched = suppliedGrid && grids.find((o) => o.slug === suppliedGrid);
71
+ if (matched) {
72
+ // Supplied grid matches — proceed. The agent should already have checked
73
+ // render_ready and warned the user; we don't block here.
74
+ return { proceed: true, grid: suppliedGrid };
75
+ }
76
+ if (grids.length > 1) {
77
+ // No valid grid supplied and multiple grids — ask once. Mark the active
78
+ // grid so the agent can offer it as the default.
79
+ const annotated = grids.map((o) => ({ ...o, is_active: o.slug === activeGrid }));
80
+ // Sort: active grid first, then ready grids, then not-ready grids.
81
+ annotated.sort((a, b) => {
82
+ if (a.is_active !== b.is_active) return b.is_active ? 1 : -1;
83
+ if (a.render_ready !== b.render_ready) return b.render_ready ? 1 : -1;
84
+ return 0;
85
+ });
86
+ const lines = ["Which grid should this be published to?"];
87
+ for (const o of annotated) {
88
+ const tags = [];
89
+ if (o.is_active) tags.push("your active grid");
90
+ if (!o.render_ready) tags.push("not set up yet");
91
+ const suffix = tags.length ? ` (${tags.join(", ")})` : "";
92
+ lines.push(` ${o.slug} — ${o.name} (${o.role})${suffix}`);
93
+ }
94
+ lines.push("Pass the grid slug in the `grid` parameter.");
95
+ const readyCount = annotated.filter((o) => o.render_ready).length;
96
+ if (readyCount === 0) {
97
+ lines.push("Note: none of your grids are fully set up yet. You can use anonymous: true as a fallback.");
98
+ }
99
+ return {
100
+ picker: {
101
+ text: lines.join("\n"),
102
+ // `needs_grid` is the new field; `needs_org`/`orgs` are kept as aliases
103
+ // so the existing org-picker.html web widget (reads data.orgs) still works.
104
+ structured: { needs_grid: true, needs_org: true, grids: annotated, orgs: annotated },
105
+ ...(edition === "web" && APPS_WIDGETS_ENABLED ? { meta: { "openai/outputTemplate": GRID_PICKER_URI } } : {}),
106
+ },
107
+ };
108
+ }
109
+ if (grids.length === 1) {
110
+ return { single: { ...grids[0], is_active: grids[0].slug === activeGrid } };
111
+ }
112
+ return { proceed: true };
113
+ }
114
+
115
+ // ── Direct-API tools (both editions) ───────────────────────────────────────────
116
+ function looksLikeFullHtml(s) {
117
+ const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
118
+ return head.startsWith("<!doctype html") || head.startsWith("<html");
119
+ }
120
+
121
+ // A real user (heavy persona-deck in Claude Desktop) hit an agent that worried
122
+ // about inline size, base64-encoded the HTML, and passed the base64 blob as
123
+ // `html` — which used to get wrapped in an HTML shell and published as a wall of
124
+ // text (an empty-looking page). Rescue that case: if the candidate text is not
125
+ // already full HTML but is a strict-base64 blob that DECODES to full HTML, use
126
+ // the decoded HTML. Applied to both the inline `html` string and the bytes read
127
+ // via `path` (a base64 `.txt` file). Returns the original text unchanged when it
128
+ // isn't base64-of-HTML, so genuine snippets are untouched.
129
+ function decodeIfBase64Html(text) {
130
+ if (typeof text !== "string" || looksLikeFullHtml(text)) {
131
+ return { html: text, wasBase64: false };
132
+ }
133
+ const stripped = text.replace(/\s+/g, "");
134
+ if (
135
+ stripped.length < 64 ||
136
+ stripped.length % 4 !== 0 ||
137
+ !/^[A-Za-z0-9+/]+={0,2}$/.test(stripped)
138
+ ) {
139
+ return { html: text, wasBase64: false };
140
+ }
141
+ let decoded;
142
+ try {
143
+ decoded = Buffer.from(stripped, "base64").toString("utf8");
144
+ } catch {
145
+ return { html: text, wasBase64: false };
146
+ }
147
+ if (looksLikeFullHtml(decoded)) {
148
+ return { html: decoded, wasBase64: true };
149
+ }
150
+ return { html: text, wasBase64: false };
151
+ }
152
+
153
+ // Heuristic: does the string look like a bare filesystem path (not HTML)? Used
154
+ // to catch a model that passes a file path — or an invented `@/home/...`
155
+ // shorthand — as `html`. Single line, no HTML tag, path-ish shape.
156
+ function looksLikePath(s) {
157
+ if (typeof s !== "string") return false;
158
+ const t = s.trim();
159
+ if (t.length === 0 || t.length > 4096) return false;
160
+ if (/[\n\r]/.test(t)) return false;
161
+ if (/<[a-z!/]/i.test(t)) return false; // contains a tag → not a path
162
+ return /^(~|\.{0,2}\/|[A-Za-z]:[\\/]|\/)/.test(t) || /\.[A-Za-z0-9]{1,8}$/.test(t);
163
+ }
164
+
165
+ // Normalize an inline `html` string — grid_deploy's ergonomic single-file publish
166
+ // path — into ONE index.html artifact, reusing the same hardening the old drop
167
+ // verb used (decodeIfBase64Html, the @-path/file-path rejection, the base64
168
+ // guard, and the small-fragment wrap). Returns { path, buffer, type }. Throws on
169
+ // a file-path-looking or non-HTML input so a path/base64 blob is never published
170
+ // as page content. The auth-aware inline size cap is enforced later in runPlug
171
+ // (it depends on the anon-vs-authed wire).
172
+ function htmlToArtifact(html, filename) {
173
+ if (typeof html !== "string" || html.length === 0) {
174
+ throw new Error("`html` must be the complete HTML document as a string.");
175
+ }
176
+ // Strip an invented `@`-prefix shorthand, then reject a bare file path — the
177
+ // inline html path takes HTML CONTENT, not a path (use `path` for a file).
178
+ let candidate = html.startsWith("@") ? html.slice(1) : html;
179
+ if (!looksLikeFullHtml(candidate) && looksLikePath(candidate)) {
180
+ throw new Error(
181
+ `This looks like a file path (\`${candidate.trim()}\`), not HTML. Pass the raw HTML inline as ` +
182
+ "`html`, or pass the file/folder via `path` — do not pass a path as `html`.",
183
+ );
184
+ }
185
+ // Rescue a base64-of-HTML blob passed as `html` (real user repro): decode it
186
+ // rather than publishing a wall of base64 text.
187
+ const { html: resolved } = decodeIfBase64Html(candidate);
188
+ let content = resolved;
189
+ if (!looksLikeFullHtml(content)) {
190
+ const stripped = content.replace(/\s+/g, "");
191
+ const looksBase64 =
192
+ stripped.length >= 64 && stripped.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(stripped);
193
+ const isShortFragment = Buffer.byteLength(content, "utf8") <= 8192;
194
+ const hasTag = /<[a-z][\s\S]*>/i.test(content);
195
+ if (isShortFragment && (hasTag || (!looksBase64 && !looksLikePath(content)))) {
196
+ // Legit "share this snippet" — wrap a small text/markup fragment into a
197
+ // full document (preserve the old drop's friendly behavior).
198
+ content =
199
+ `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
200
+ `<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
201
+ } else {
202
+ // Large, or base64 that failed to decode to HTML, or a bare file path:
203
+ // refuse instead of silently publishing garbage.
204
+ const kind = looksBase64 ? "base64" : looksLikePath(content) ? "a file path" : "raw data";
205
+ throw new Error(
206
+ `This doesn't look like an HTML document (it looks like ${kind}). Pass the raw HTML as \`html\`, ` +
207
+ "or in the local edition pass `path` to the .html file — do NOT base64-encode it.",
208
+ );
209
+ }
210
+ }
211
+ return { path: filename || "index.html", buffer: Buffer.from(content, "utf8"), type: "text/html" };
212
+ }
213
+
214
+ // Anonymous drops are owned by the platform Guest Org, whose slug is the apex
215
+ // the public URL hangs off (https://guest.cloudgrid.io/<slug>).
216
+ const GUEST_ORG_SLUG = "guest";
217
+
218
+ // FALLBACK-ONLY URL composition. Since the unified plug contract (spec v2 /
219
+ // the unified plug spec), `/api/v2/plug` returns a server-composed canonical `url` on every
220
+ // path (create + edit, anon + authed) — flat-arch-aware per grid, matching the
221
+ // host that actually serves. ALWAYS prefer `data.url` (see resolvePlugUrl);
222
+ // this client-side derivation exists only for the rare response where `url`
223
+ // came back empty (the server composes it best-effort). It mirrors the legacy
224
+ // `entityUrl()` rules and is WRONG on flat-arch grids (which serve
225
+ // `<slug>--<grid>.cloudgrid.io`), so it must never be the primary source:
226
+ // - inspiration (HTML drops): path-based at the org apex
227
+ // https://<grid>.cloudgrid.io/<slug>
228
+ // - runtime (app/agent): subdomain
229
+ // https://<slug>.<grid>.cloudgrid.io
230
+ // Anonymous drops are grid-less in the response (`grid: null`); they live under
231
+ // the Guest Org, so the apex slug is the constant `guest`.
232
+ function composePlugUrl(data) {
233
+ const slug = data?.slug;
234
+ if (!slug) return null;
235
+ const grid = data?.grid || GUEST_ORG_SLUG;
236
+ const kind = data?.detection?.kind;
237
+ if (kind === "app" || kind === "agent") {
238
+ return `https://${slug}.${grid}.cloudgrid.io`;
239
+ }
240
+ // inspiration (and any unknown/static kind) — path-based at the org apex.
241
+ return `https://${grid}.cloudgrid.io/${slug}`;
242
+ }
243
+
244
+ // Parse the top-level `name:` from a cloudgrid.yaml manifest (issue #48). The
245
+ // manifest is the source of truth for the entity name on a create, but the
246
+ // inline-create wire never forwarded it, so an `artifact_files` create landed as
247
+ // an auto `drop-XXXX` slug. Deliberately a tiny top-level-scalar scan (no YAML
248
+ // dep, matching the rest of this module): the FIRST unindented `name:` key wins,
249
+ // nested `services:`/`needs:` `name:` keys (indented) are ignored, and quotes +
250
+ // inline `# comments` are stripped. Returns null when absent/unparseable.
251
+ export function parseManifestName(yaml) {
252
+ if (typeof yaml !== "string" || yaml.length === 0) return null;
253
+ for (const rawLine of yaml.split(/\r?\n/)) {
254
+ // Top-level keys only — a leading space/tab means it is nested under a map.
255
+ if (/^\s/.test(rawLine)) continue;
256
+ const m = /^name:\s*(.+?)\s*$/.exec(rawLine);
257
+ if (!m) continue;
258
+ let val = m[1];
259
+ // Strip an inline comment on an unquoted scalar.
260
+ if (!/^["']/.test(val)) val = val.replace(/\s+#.*$/, "").trim();
261
+ // Strip surrounding quotes.
262
+ val = val.replace(/^(["'])(.*)\1$/, "$2").trim();
263
+ return val.length > 0 ? val : null;
264
+ }
265
+ return null;
266
+ }
267
+
268
+ // Detect whether a CREATE's source already carries a cloudgrid.yaml (i.e. it's
269
+ // a pre-configured runtime app). Returns a light summary { name, services, needs, raw }
270
+ // or null. Pure except the injectable disk read (path source).
271
+ export function detectSourceManifest(input, deps = {}) {
272
+ const readManifestFile = deps.readManifestFile || ((p) => {
273
+ try { return existsSync(p) ? readFileSync(p, "utf8") : null; } catch { return null; }
274
+ });
275
+ let yaml = null;
276
+ if (typeof input?.cloudgrid_yaml === "string" && input.cloudgrid_yaml.trim()) {
277
+ yaml = input.cloudgrid_yaml;
278
+ } else if (Array.isArray(input?.artifact_files)) {
279
+ // Only the ROOT cloudgrid.yaml is a runtime manifest — the server builds
280
+ // from the root. A nested one (services/web/cloudgrid.yaml) is not.
281
+ const entry = input.artifact_files.find((f) => f?.path === "cloudgrid.yaml");
282
+ if (entry?.content) yaml = entry.content;
283
+ } else if (typeof input?.path === "string" && input.path) {
284
+ yaml = readManifestFile(join(input.path, "cloudgrid.yaml"));
285
+ }
286
+ if (!yaml) return null;
287
+ const name = parseManifestName(yaml);
288
+ // lightweight surface for the confirm prompt (no full YAML parser needed).
289
+ // Scope each list to its own top-level block: collect only the immediate
290
+ // child keys of `services:`/`needs:` — a shared 2-space regex would grab a
291
+ // `needs:` child (e.g. `database`) as a bogus service.
292
+ const lines = yaml.split(/\r?\n/);
293
+ const blockChildren = (blockKey) => {
294
+ const out = [];
295
+ let inBlock = false;
296
+ let childIndent = null;
297
+ for (const line of lines) {
298
+ if (/^\S/.test(line)) {
299
+ // a top-level key: enters the target block, or ends it
300
+ inBlock = new RegExp(`^${blockKey}:\\s*$`).test(line);
301
+ childIndent = null;
302
+ continue;
303
+ }
304
+ if (!inBlock || line.trim() === "") continue;
305
+ const indent = line.match(/^(\s*)/)[1].length;
306
+ if (childIndent === null) childIndent = indent;
307
+ if (indent !== childIndent) continue; // deeper nesting (a child's props)
308
+ const m = line.trim().match(/^([a-z0-9-]+):/i);
309
+ if (m) out.push(m[1]);
310
+ }
311
+ return out;
312
+ };
313
+ const services = blockChildren("services");
314
+ const needs = blockChildren("needs");
315
+ return { name: name || null, services, needs, raw: yaml };
316
+ }
317
+
318
+ // The public URL of a `/plug` response: the server-composed `url` verbatim
319
+ // (canonical, flat-arch-aware — the unified plug spec), falling back to client-side composition
320
+ // ONLY when the server left it empty (its composition is best-effort).
321
+ export function resolvePlugUrl(data) {
322
+ if (typeof data?.url === "string" && data.url.length > 0) return data.url;
323
+ return composePlugUrl(data);
324
+ }
325
+
326
+
327
+ // ── Consent-gated error reporting (Task 34 / 0.8.1) ──────────────────────────
328
+ // Key names that look like they carry a secret. Mirrors the server's
329
+ // SECRET_KEY_PATTERNS (packages/api/src/routes/errors.ts) so the MCP scrubs the
330
+ // same shapes client-side — defense-in-depth on top of the server redaction.
331
+ const REPORT_SECRET_KEY_PATTERNS = [
332
+ /password/i,
333
+ /secret/i,
334
+ /token/i,
335
+ /api[_-]?key/i,
336
+ /auth/i,
337
+ /credential/i,
338
+ /private[_-]?key/i,
339
+ /access[_-]?key/i,
340
+ ];
341
+
342
+ // Light client-side scrub of obviously secret-looking values in the report
343
+ // context. Redacts values under secret-looking KEYS; leaves everything else
344
+ // intact (the server does the authoritative redaction). Bounded depth so a
345
+ // pathological object can't loop.
346
+ export function scrubReportContext(obj, depth = 0) {
347
+ if (depth > 5 || obj === null || typeof obj !== "object") return obj;
348
+ if (Array.isArray(obj)) return obj.map((item) => scrubReportContext(item, depth + 1));
349
+ const out = {};
350
+ for (const [key, value] of Object.entries(obj)) {
351
+ const isSecret = REPORT_SECRET_KEY_PATTERNS.some((re) => re.test(key));
352
+ out[key] = isSecret ? "[REDACTED]" : scrubReportContext(value, depth + 1);
353
+ }
354
+ return out;
355
+ }
356
+
357
+ // Send a consent-gated bug report to the CloudGrid team. The agent calls this
358
+ // ONLY after the user explicitly agrees (see errorGuidance + the PLAYBOOK rule).
359
+ //
360
+ // Matches the CLI reporter (packages/cli error-reporter.ts → client.reportError):
361
+ // POST /api/v2/errors with the CLI payload shape
362
+ // { type:'error', category, app, message, stack?, context, trace_id?,
363
+ // failed_step?, http_status?, cli_version, node_version, platform }
364
+ // so CLI + MCP reports land uniformly in the `errors` collection.
365
+ //
366
+ // Source attribution (Gilad's ask): every report says WHERE it came from —
367
+ // source (mcp-stdio | mcp-hosted), client (the calling agent from MCP clientInfo),
368
+ // platform, mcp_version. Sent BOTH as top-level fields AND mirrored in
369
+ // `context.origin`. The POST /errors handler only persists known top-level keys
370
+ // (it drops unknown ones), and `context` is stored + secret-stripped server-side,
371
+ // so `context.origin` is the durable carrier — belt-and-suspenders.
372
+ //
373
+ // Auth: signed-in → Bearer; anon+web → the trusted-server headers (works once the
374
+ // endpoint accepts the credential; until then a 401 degrades to "sign in to
375
+ // report"). Honors CLOUDGRID_TELEMETRY=off (matches the CLI). Never throws.
376
+ export async function runReport(
377
+ ctx,
378
+ { message, context, include_conversation, category, trace_id, failed_step, http_status } = {},
379
+ ) {
380
+ const summary = typeof message === "string" ? message.trim() : "";
381
+ if (!summary) {
382
+ return okResult({
383
+ text: "Nothing to report — provide a short `message` describing what failed.",
384
+ structured: { status: "skipped" },
385
+ });
386
+ }
387
+
388
+ // Privacy escape hatch — no telemetry when explicitly disabled (matches the CLI
389
+ // reporter's CLOUDGRID_TELEMETRY=off). Consent still gates the call regardless;
390
+ // this is the belt-and-suspenders global opt-out. Nothing leaves the process.
391
+ if (process.env.CLOUDGRID_TELEMETRY === "off") {
392
+ return okResult({
393
+ text: "Error reporting is disabled (CLOUDGRID_TELEMETRY=off) — nothing was sent.",
394
+ structured: { status: "disabled" },
395
+ });
396
+ }
397
+
398
+ // ── Source attribution ──────────────────────────────────────────────────────
399
+ // source: mcp-stdio (local edition) | mcp-hosted (web edition).
400
+ const source = ctx.edition === "web" ? "mcp-hosted" : "mcp-stdio";
401
+ // client: the calling agent captured from the MCP clientInfo at initialize.
402
+ // Falls back to "unknown" — a report must never fail on missing client info.
403
+ const ci = ctx.state?.client;
404
+ const client =
405
+ ci && ci.name
406
+ ? ci.version
407
+ ? `${ci.name} ${ci.version}`
408
+ : String(ci.name)
409
+ : "unknown";
410
+ const platform = `${process.platform} ${process.arch}`;
411
+
412
+ // Belt-and-suspenders scrub before the value ever leaves the process. The
413
+ // origin block is authored by us (not user/agent input), so it is appended
414
+ // AFTER the scrub — it carries no secrets and must survive verbatim.
415
+ const scrubbed =
416
+ context && typeof context === "object" ? scrubReportContext(context) : {};
417
+ const safeContext = {
418
+ ...scrubbed,
419
+ origin: {
420
+ source,
421
+ client,
422
+ platform,
423
+ mcp_version: MCP_VERSION,
424
+ },
425
+ };
426
+
427
+ // The full conversation is NEVER included unless the agent explicitly set the
428
+ // flag (which the PLAYBOOK gates on the user's explicit yes). This tool only
429
+ // records the flag alongside the report so intent is auditable — it does not
430
+ // itself have the transcript.
431
+ const body = {
432
+ type: "error",
433
+ // category: default "mcp" (or the failing tool name the agent passes).
434
+ category: typeof category === "string" && category.trim() ? category.trim() : "mcp",
435
+ app: "mcp",
436
+ message: summary.slice(0, 5000),
437
+ context: safeContext,
438
+ // Diagnostic pivots (match the CLI) — only when the agent forwards them.
439
+ ...(typeof trace_id === "string" && trace_id ? { trace_id } : {}),
440
+ ...(typeof failed_step === "string" && failed_step ? { failed_step } : {}),
441
+ ...(typeof http_status === "number" && Number.isFinite(http_status) ? { http_status } : {}),
442
+ // Attribution, ALSO top-level (persisted once the handler accepts these keys;
443
+ // context.origin is the fallback until then).
444
+ source,
445
+ client,
446
+ platform,
447
+ // cli_version stays null for MCP-originated reports; mcp_version carries our
448
+ // version in context.origin (the CLI-analog lives there).
449
+ cli_version: null,
450
+ node_version: process.version,
451
+ ...(include_conversation === true ? { include_conversation: true } : {}),
452
+ };
453
+
454
+ // Auth: signed-in → Bearer; anon + web edition → trusted-server headers.
455
+ const headers = { "content-type": "application/json" };
456
+ let token = null;
457
+ try {
458
+ token = await ctx.getToken();
459
+ } catch {
460
+ token = null;
461
+ }
462
+ let usedTrustedServer = false;
463
+ if (token) {
464
+ headers["Authorization"] = `Bearer ${token}`;
465
+ } else if (ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
466
+ // Web edition anon path — works ONCE the endpoint accepts the trusted-server
467
+ // credential (Gilad-side change). Until then the server 401s and we degrade.
468
+ headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
469
+ headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
470
+ usedTrustedServer = true;
471
+ }
472
+
473
+ let res;
474
+ try {
475
+ res = await fetch(`${API_BASE}/api/v2/errors`, {
476
+ method: "POST",
477
+ headers,
478
+ body: JSON.stringify(body),
479
+ });
480
+ } catch (err) {
481
+ return okResult({
482
+ text: `Couldn't reach the CloudGrid team right now (${err.message}). Nothing was sent — you can try again later.`,
483
+ structured: { status: "error" },
484
+ });
485
+ }
486
+
487
+ if (res.status === 201 || res.ok) {
488
+ return okResult({
489
+ text: "Reported to the CloudGrid team — thank you.",
490
+ structured: { status: "recorded" },
491
+ });
492
+ }
493
+ if (res.status === 429) {
494
+ return okResult({
495
+ text: "Already reported a lot recently; try again later.",
496
+ structured: { status: "rate_limited" },
497
+ });
498
+ }
499
+ if (res.status === 401) {
500
+ // Anon reporting isn't accepted yet (needs the Gilad-side endpoint change);
501
+ // degrade gracefully rather than erroring.
502
+ return okResult({
503
+ text: usedTrustedServer || !token
504
+ ? "Sign in to send a report to the CloudGrid team (grid_login), then try again."
505
+ : "That didn't authorize a report. Sign in again (grid_login) and retry.",
506
+ structured: { status: "unauthorized" },
507
+ });
508
+ }
509
+ return okResult({
510
+ text: "Couldn't send the report to the CloudGrid team right now. Nothing else was sent — you can try again later.",
511
+ structured: { status: "error" },
512
+ });
513
+ }
514
+
515
+ export async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
516
+ const token = await ctx.getToken();
517
+ if (!token) {
518
+ throw new Error("You are not signed in. Run grid_login first, then claim.");
519
+ }
520
+
521
+ // `/api/v2/anon-claim` (claim-token-in-body, returns a list) was retired; the
522
+ // claim now runs through `POST /api/v2/entities/:id/pickup`, which takes the
523
+ // entity id in the PATH and the `claim_token` in the body, and re-homes the
524
+ // Guest-Org inspiration into the caller's grid (ownership transfer). The
525
+ // claim token IS the anonymous owner token (Anon owner-token contract: one bearer
526
+ // capability for both edit and claim); an anon edit re-mints it, and the
527
+ // session state always holds the freshest one. The anon-session cookie is the
528
+ // token-less alternative auth.
529
+ let claimToken = claim_token;
530
+ if (!claimToken && claim_url) {
531
+ try {
532
+ claimToken = new URL(claim_url).searchParams.get("token");
533
+ } catch {
534
+ claimToken = null;
535
+ }
536
+ }
537
+ if (!claimToken && ctx.state.lastAnonClaim) claimToken = ctx.state.lastAnonClaim.token;
538
+
539
+ // Pickup needs the target entity id in the URL path. Prefer an explicit one,
540
+ // else the entity remembered from this session's anonymous drop.
541
+ const targetId =
542
+ entity_id ||
543
+ ctx.state.lastAnonClaim?.entity_id ||
544
+ // The last drop counts only when it is anon-owned (it holds an owner token).
545
+ (ctx.state.lastDrop?.owner_token ? ctx.state.lastDrop.entity_id : null);
546
+ if (!claimToken && targetId && ctx.state.lastDrop?.entity_id === targetId) {
547
+ claimToken = ctx.state.lastDrop.owner_token ?? null;
548
+ }
549
+ if (!targetId) {
550
+ throw new Error(
551
+ "No drop to claim. Pass entity_id (or drop something anonymously first in this session), " +
552
+ "so the pickup knows which entity to claim.",
553
+ );
554
+ }
555
+
556
+ const headers = {
557
+ Authorization: `Bearer ${token}`,
558
+ "Content-Type": "application/json",
559
+ };
560
+ // The claim re-homes the inspiration into the caller's grid; the platform
561
+ // resolves the destination from the active-org context, so send the org
562
+ // header (the same header every other authed write uses).
563
+ const orgSlug = await ctx.getActiveGrid();
564
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
565
+ if (orgSlug) {
566
+ headers["X-CloudGrid-Grid"] = orgSlug;
567
+ headers["X-CloudGrid-Org"] = orgSlug;
568
+ }
569
+ // Replay the anon-session cookie so a cookie-class caller can claim what it
570
+ // dropped, even without a claim token.
571
+ if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
572
+
573
+ let res;
574
+ try {
575
+ res = await fetch(`${API_BASE}/api/v2/entities/${encodeURIComponent(targetId)}/pickup`, {
576
+ method: "POST",
577
+ headers,
578
+ body: JSON.stringify(claimToken ? { claim_token: claimToken } : {}),
579
+ });
580
+ } catch (err) {
581
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
582
+ }
583
+
584
+ const raw = await res.text();
585
+ let data = null;
586
+ try {
587
+ data = JSON.parse(raw);
588
+ } catch {
589
+ /* handled below */
590
+ }
591
+ if (!res.ok) {
592
+ // 409 ALREADY_CLAIMED is the idempotent "nothing left to do" outcome.
593
+ if (res.status === 409) {
594
+ ctx.state.lastAnonClaim = null;
595
+ return {
596
+ text: "Nothing to claim — it was already claimed.",
597
+ structured: { claimed: 0, urls: [] },
598
+ };
599
+ }
600
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
601
+ throw new Error(`Claim failed (HTTP ${res.status}): ${msg}`);
602
+ }
603
+
604
+ ctx.state.lastAnonClaim = null;
605
+ // The entity is authed-owned now — the anon owner token is dead weight (a
606
+ // claimed drop can no longer be edited anonymously). Future re-drops of it
607
+ // must ride the authed wire.
608
+ if (ctx.state.lastDrop?.entity_id === targetId) {
609
+ ctx.state.lastDrop.owner_token = null;
610
+ }
611
+ const url = data?.url || data?.redirect_url || ctx.state.lastDrop?.url || null;
612
+ const lines = ["Claimed 1, now yours:"];
613
+ lines.push(`${url ?? ""}${data?.new_expires_at ? ` (expires ${data.new_expires_at})` : ""}`.trim());
614
+ return {
615
+ text: lines.join("\n"),
616
+ structured: {
617
+ claimed: 1,
618
+ urls: url ? [url] : [],
619
+ },
620
+ };
621
+ }
622
+
623
+
624
+ // ── grid_deploy — the unified create/re-plug verb (spec v2 §3) ──────────────
625
+
626
+ // Total upload budget mirrors the server's multipart cap (100 MB).
627
+ const PLUG_MAX_TOTAL_BYTES = 100 * 1024 * 1024;
628
+ const PLUG_MAX_FILES = 2000;
629
+
630
+ // Directories never worth uploading, regardless of ignore files.
631
+ const PLUG_ALWAYS_SKIP = new Set([".git", "node_modules", ".DS_Store", ".cloudgrid"]);
632
+
633
+ // Compile one .gitignore/.cloudgridignore pattern into a matcher over
634
+ // repo-relative paths. A pragmatic subset (no negation `!` — those lines are
635
+ // skipped): `#` comments, `*`/`?`/`**` globs, a leading `/` anchors to the
636
+ // root, a trailing `/` matches directories only, and a bare name matches at
637
+ // any depth (standard gitignore semantics for patterns without a slash).
638
+ function compileIgnorePattern(line) {
639
+ let pat = line.trim();
640
+ if (!pat || pat.startsWith("#") || pat.startsWith("!")) return null;
641
+ const dirOnly = pat.endsWith("/");
642
+ if (dirOnly) pat = pat.slice(0, -1);
643
+ // A pattern containing a slash is anchored to the root (gitignore rule).
644
+ const anchored = pat.includes("/");
645
+ if (pat.startsWith("/")) pat = pat.slice(1);
646
+ const rx = pat
647
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
648
+ .replace(/\*\*/g, "")
649
+ .replace(/\*/g, "[^/]*")
650
+ .replace(/\?/g, "[^/]")
651
+ .replace(//g, ".*");
652
+ const body = anchored ? `^${rx}` : `(^|/)${rx}`;
653
+ const re = new RegExp(`${body}(/|$)`);
654
+ return { re, dirOnly };
655
+ }
656
+
657
+ function loadIgnoreMatchers(rootDir) {
658
+ const patterns = [];
659
+ for (const f of [".gitignore", ".cloudgridignore"]) {
660
+ try {
661
+ const p = join(rootDir, f);
662
+ if (existsSync(p)) {
663
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
664
+ const compiled = compileIgnorePattern(line);
665
+ if (compiled) patterns.push(compiled);
666
+ }
667
+ }
668
+ } catch {
669
+ /* unreadable ignore file — upload everything */
670
+ }
671
+ }
672
+ return (relPath, isDir) =>
673
+ patterns.some((p) => (!p.dirOnly || isDir) && p.re.test(relPath));
674
+ }
675
+
676
+ // Walk a local folder into `[{path, buffer}]` artifacts (repo-relative paths),
677
+ // honoring .gitignore/.cloudgridignore at the root plus the always-skip set.
678
+ // A single file becomes one artifact named by its basename.
679
+ function collectPathArtifacts(srcPath) {
680
+ const abs = resolve(srcPath);
681
+ if (!existsSync(abs)) throw new Error(`Path does not exist: ${abs}`);
682
+ const st = statSync(abs);
683
+ if (st.isFile()) {
684
+ return [{ path: basename(abs), buffer: readFileSync(abs) }];
685
+ }
686
+ if (!st.isDirectory()) throw new Error(`Not a file or directory: ${abs}`);
687
+ const isIgnored = loadIgnoreMatchers(abs);
688
+ const out = [];
689
+ let total = 0;
690
+ const walk = (dir, rel) => {
691
+ for (const nm of readdirSync(dir)) {
692
+ if (PLUG_ALWAYS_SKIP.has(nm)) continue;
693
+ const childAbs = join(dir, nm);
694
+ const childRel = rel ? `${rel}/${nm}` : nm;
695
+ let cst;
696
+ try {
697
+ cst = statSync(childAbs);
698
+ } catch {
699
+ continue; // broken symlink etc.
700
+ }
701
+ if (isIgnored(childRel, cst.isDirectory())) continue;
702
+ if (cst.isDirectory()) {
703
+ walk(childAbs, childRel);
704
+ } else if (cst.isFile()) {
705
+ if (out.length >= PLUG_MAX_FILES) {
706
+ throw new Error(
707
+ `The folder has more than ${PLUG_MAX_FILES} files after ignores — too large to plug inline. Trim it or add a .cloudgridignore.`,
708
+ );
709
+ }
710
+ total += cst.size;
711
+ if (total > PLUG_MAX_TOTAL_BYTES) {
712
+ throw new Error("The upload exceeds the 100MB plug limit. Trim the folder or add a .cloudgridignore.");
713
+ }
714
+ out.push({ path: childRel, buffer: readFileSync(childAbs) });
715
+ }
716
+ }
717
+ };
718
+ walk(abs, "");
719
+ if (out.length === 0) throw new Error(`Nothing to upload in ${abs} (everything ignored or empty).`);
720
+ return out;
721
+ }
722
+
723
+ // ── Self-healing error guidance (Task 31 / 0.7.2) ────────────────────────────
724
+ // Map a KNOWN failure code to a short, agent-facing next-step sentence appended
725
+ // to the raw server error. Returns null for anything unknown — callers MUST let
726
+ // unknown errors pass through UNCHANGED (no blanket rewriting). Pure and
727
+ // exported so the unit tests can assert the mapping directly.
728
+ //
729
+ // Context flags:
730
+ // edition — "local" | "web"; steers the SCOPE_INVALID wording (the local
731
+ // edition self-heals via the bundled CLI; the web edition cannot).
732
+ // isEdit — a re-plug (target_entity_id present) vs a create.
733
+ // isAnon — the call already rode the anonymous wire.
734
+ // signedIn — the caller has a usable auth token (steers the 429 wording).
735
+ export function errorGuidance({ status, code, edition, isEdit, isAnon, signedIn } = {}) {
736
+ // 400 SCOPE_INVALID — the known platform bug: the /plug create branch ignores
737
+ // scope/visibility on a signed-in create and 400s (scope=personal,
738
+ // visibility=grid). It does NOT affect re-plug of an existing entity.
739
+ if (status === 400 && code === "SCOPE_INVALID") {
740
+ // Anonymous creates don't hit this branch; if one somehow reports it, there
741
+ // is no self-heal path — say nothing edition-specific.
742
+ if (isAnon) return null;
743
+ if (isEdit) {
744
+ // A re-plug that 400s here is not the create bug — no special guidance.
745
+ return null;
746
+ }
747
+ if (edition === "local") {
748
+ return "Known platform issue with signed-in creates via the plug API. Falling back to the bundled CloudGrid CLI…";
749
+ }
750
+ // web (and any non-local edition): no CLI to fall back to.
751
+ return (
752
+ "Known platform issue with signed-in creates via the plug API. " +
753
+ "Re-plug of an existing entity still works; creating new entities is temporarily affected — " +
754
+ "do NOT retry with other parameters and do NOT fall back to anonymous."
755
+ );
756
+ }
757
+ // 429 — the daily anonymous cap. Never a sign-in problem; do not loop on login.
758
+ if (status === 429) {
759
+ return (
760
+ "Do not retry today and do not treat this as a sign-in problem. " +
761
+ "If the user is signed in, use the signed-in path instead of anonymous."
762
+ );
763
+ }
764
+ // 409 EDIT_REJECTED — an in-place re-plug the server won't take.
765
+ if (status === 409) {
766
+ return "The entity cannot be updated right now (a deploy is in progress, or it is archived/expired/claimed). An explicit re-plug never silently creates; retry later, or omit target_entity_id to create a new entity.";
767
+ }
768
+ // 401 on an edit — the credential didn't authorize this entity.
769
+ if (status === 401) {
770
+ return isEdit
771
+ ? "That did not authorize this entity (wrong entity, expired, or already claimed). Sign in if you own it (grid_login), pass its owner_token for an anonymously-created drop, or omit target_entity_id to create a new entity."
772
+ : "Sign in (grid_login), or for an anonymously-created drop pass its owner_token.";
773
+ }
774
+ if (status === 403) {
775
+ return "You lack the role to plug this target. To re-plug someone else's entity, pick it up first (grid_edit_existing_app / grid_claim_anonymous_deploy).";
776
+ }
777
+ // ── Consent-gated report offer (Task 34) ──────────────────────────────────
778
+ // GENUINE bugs only: a build/deploy failure, any 5xx, INTERNAL_ERROR, or an
779
+ // unknown/unmapped error. Everything above (429 rate-limit, needs_grid picker,
780
+ // 401 sign-in prompts, 409 EDIT_REJECTED, 403) is an EXPECTED condition, not a
781
+ // bug — those returned already and never reach here, so they never get the
782
+ // offer. The offer tells the agent to ask permission first and never send the
783
+ // full conversation without an explicit yes.
784
+ const isServerError = typeof status === "number" && status >= 500;
785
+ const isInternalError = code === "INTERNAL_ERROR";
786
+ const isBuildFailure = code === "BUILD_FAILED" || code === "DEPLOY_FAILED";
787
+ if (isServerError || isInternalError || isBuildFailure) {
788
+ return REPORT_OFFER;
789
+ }
790
+ // A 4xx with an unknown/unmapped code is a client-side condition (validation,
791
+ // bad input), not a server bug — pass through unchanged so callers don't
792
+ // rewrite it or wrongly offer to report it.
793
+ return null;
794
+ }
795
+
796
+ // The consent-gated report affordance appended to genuine-bug guidance. It
797
+ // instructs the agent to get explicit permission before calling grid_report,
798
+ // and to never send the whole conversation without an explicit yes.
799
+ export const REPORT_OFFER =
800
+ "If this looks like a CloudGrid bug, ASK the user for permission to report it to the CloudGrid team, " +
801
+ "then call grid_report with the error + the failed request context. " +
802
+ "Do NOT report without an explicit yes, and do NOT include the full conversation unless the user explicitly agrees.";
803
+
804
+ // Map friendly plug error statuses to actionable messages (spec v2 §3.3).
805
+ // Appends errorGuidance() for known codes; unknown codes pass through as the
806
+ // bare `base` line, unchanged.
807
+ function plugErrorMessage(status, code, msg, ctxFlags = {}) {
808
+ const base = `Plug failed (HTTP ${status}${code ? ` ${code}` : ""}): ${msg}`;
809
+ const guidance = errorGuidance({ status, code, ...ctxFlags });
810
+ return guidance ? `${base} — ${guidance}` : base;
811
+ }
812
+
813
+ // Parse the live URL the CLI prints on a successful `plug`. The CLI prints the
814
+ // canonical https://…cloudgrid.io URL somewhere in stdout (labelled "Outlet"
815
+ // / "Live" / bare); take the last cloudgrid.io URL it emits — the final line is
816
+ // the deployed URL, not an intermediate build/log link.
817
+ export function parseCliPlugUrl(stdout) {
818
+ const matches = String(stdout || "").match(/https?:\/\/[^\s'"<>)\]]*cloudgrid\.io[^\s'"<>)\]]*/g);
819
+ if (!matches || matches.length === 0) return null;
820
+ return matches[matches.length - 1].replace(/[.,;]+$/, "");
821
+ }
822
+
823
+ // LOCAL-EDITION SELF-HEAL RUNG (Task 31). When a signed-in CREATE via /plug hits
824
+ // the known 400 SCOPE_INVALID platform bug, re-run the create through the
825
+ // bundled CloudGrid CLI (whose wire is unaffected). Writes the in-memory
826
+ // artifacts to a temp dir, runs `plug <dir> --no-clipboard --no-notify`, parses
827
+ // the live URL, and returns a normal runPlug-shaped success. Always cleans up
828
+ // the temp dir. The `run` dep is a seam for tests (defaults to runCloudgrid).
829
+ //
830
+ // Caller MUST gate this: local edition, create only (never edits), signed-in
831
+ // (never anonymous), and only for the SCOPE_INVALID failure.
832
+ export async function plugViaCliFallback(ctx, artifacts, deps = {}) {
833
+ const { run = runCloudgrid, makeTmp = () => mkdtemp(join(tmpdir(), "cloudgrid-plug-")) } = deps;
834
+ const dir = await makeTmp();
835
+ try {
836
+ const root = resolve(dir);
837
+ for (const a of artifacts) {
838
+ const dest = resolve(root, a.path);
839
+ // Containment guard: never let an artifact path escape the temp dir.
840
+ if (dest !== root && !dest.startsWith(root + sep)) {
841
+ throw new Error(`Refusing to write artifact outside the temp dir: ${a.path}`);
842
+ }
843
+ await mkdir(dirname(dest), { recursive: true });
844
+ await writeFile(dest, a.buffer);
845
+ }
846
+ const stdout = await run(["plug", dir, "--no-clipboard", "--no-notify"]);
847
+ const url = parseCliPlugUrl(stdout);
848
+ if (!url) {
849
+ throw new Error(
850
+ `CLI fallback ran but no live URL was found in its output.\n${String(stdout || "").slice(0, 500)}`,
851
+ );
852
+ }
853
+ // The CLI created the entity; keep session continuity loosely (no entity_id
854
+ // is parsed from stdout, so a later re-plug rides the create path again).
855
+ ctx.state.lastAnonClaim = null;
856
+ return {
857
+ text:
858
+ `Live: ${url}\n` +
859
+ "(Recovered via the bundled CloudGrid CLI — the signed-in plug API hit a known platform issue, so the CLI published this instead.)",
860
+ structured: { url, status: "created", via: "cli-fallback" },
861
+ };
862
+ } finally {
863
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
864
+ }
865
+ }
866
+
867
+ /**
868
+ * The unified create/re-plug verb (spec v2 §3). Two intents on one tool, keyed
869
+ * by `target_entity_id`:
870
+ * - absent → CREATE: mint a new entity from the artifact (server detection
871
+ * decides the kind unless hinted). Authed → the caller's grid; anon → a
872
+ * Guest-Grid drop with a claim_url + owner_token.
873
+ * - present → RE-PLUG: update the SAME entity in place (same id, slug, URL,
874
+ * history). Authed for entities in your grid; `owner_token` for a drop
875
+ * minted anonymously (anon owner-token contract).
876
+ * Source is `path` (local edition — the folder/file is read and uploaded) XOR
877
+ * `artifact_files` (hosted — inline file entries).
878
+ */
879
+ export async function runPlug(ctx, input, deps = {}) {
880
+ const { fetchImpl = fetch, uploadTimeoutMs = PLUG_UPLOAD_TIMEOUT_MS } = deps;
881
+ const {
882
+ path: srcPath,
883
+ artifact_files,
884
+ html,
885
+ filename,
886
+ cloudgrid_yaml,
887
+ target_entity_id,
888
+ grid,
889
+ slug,
890
+ hints,
891
+ anon,
892
+ owner_token,
893
+ } = input || {};
894
+
895
+ // ── Source: exactly one of html | artifact_files | path ─────────────────────
896
+ const hasHtml = typeof html === "string" && html.length > 0;
897
+ const hasArtifacts = Array.isArray(artifact_files) && artifact_files.length > 0;
898
+ const hasPath = Boolean(srcPath);
899
+ if ((hasHtml ? 1 : 0) + (hasArtifacts ? 1 : 0) + (hasPath ? 1 : 0) > 1) {
900
+ throw new Error(
901
+ "Pass exactly one source: `html` (a single inline HTML document), `artifact_files` " +
902
+ "(multiple inline files), or `path` (a local file/folder).",
903
+ );
904
+ }
905
+ if (ctx.edition === "web" && hasPath) {
906
+ throw new Error(
907
+ "The hosted server cannot read local files — pass the source inline via `html` or `artifact_files`.",
908
+ );
909
+ }
910
+ let artifacts;
911
+ // Set on the single-file `html` path so the auth-aware inline size cap can be
912
+ // enforced once the anon-vs-authed wire is known (see below).
913
+ let inlineHtmlBytes = null;
914
+ if (hasHtml) {
915
+ // The ergonomic single-file publish path (the old drop verb): one self-
916
+ // contained HTML document → one index.html artifact, with the shared
917
+ // hardening (base64 rescue, @-path/file-path rejection, fragment wrap).
918
+ const art = htmlToArtifact(html, filename);
919
+ inlineHtmlBytes = art.buffer.byteLength;
920
+ artifacts = [art];
921
+ } else if (hasPath) {
922
+ artifacts = collectPathArtifacts(srcPath);
923
+ } else if (hasArtifacts) {
924
+ let total = 0;
925
+ artifacts = artifact_files.map((f) => {
926
+ if (!f || typeof f.path !== "string" || typeof f.content !== "string") {
927
+ throw new Error("Each artifact_files entry needs `path` and `content`.");
928
+ }
929
+ const buffer = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
930
+ total += buffer.byteLength;
931
+ if (total > PLUG_MAX_TOTAL_BYTES) {
932
+ throw new Error("The upload exceeds the 100MB plug limit.");
933
+ }
934
+ return { path: f.path, buffer };
935
+ });
936
+ } else {
937
+ throw new Error(
938
+ ctx.edition === "web"
939
+ ? "Provide the source via `html` (a single inline HTML document) or `artifact_files`."
940
+ : "Provide the source via `html` (a single inline HTML document), `path` (a local file " +
941
+ "or folder), or `artifact_files`.",
942
+ );
943
+ }
944
+
945
+ // grid+slug re-plug handle (the pickup contract's `replug_handle`): when no
946
+ // explicit target_entity_id was given but a grid+slug pair is, resolve it to
947
+ // an existing entity_id and re-plug that in place. A slug that does NOT resolve
948
+ // to an existing entity → targetEntityId stays empty → this is a CREATE (no
949
+ // false-positive re-plug). target_entity_id remains the primary/documented
950
+ // handle. Best-effort resolve (pickup contract); never fetches the public URL.
951
+ let targetEntityId = target_entity_id;
952
+ if ((typeof targetEntityId !== "string" || targetEntityId.length === 0) && grid && slug) {
953
+ const resolved = await resolveEntityViaPickup(ctx, { target: slug, grid });
954
+ if (resolved?.entity_id) targetEntityId = resolved.entity_id;
955
+ }
956
+
957
+ const isEdit = typeof targetEntityId === "string" && targetEntityId.length > 0;
958
+
959
+ // An inspiration edit content-versions the FIRST uploaded artifact — when a
960
+ // multi-file folder rides a re-plug, put the primary entry first so the edit
961
+ // swaps the right file (index.html > any .html > everything else).
962
+ if (isEdit && artifacts.length > 1) {
963
+ const prio = (a) =>
964
+ a.path === "index.html" ? 0 : /\.html?$/i.test(a.path) ? 1 : a.path.startsWith(".") ? 3 : 2;
965
+ artifacts = artifacts
966
+ .map((a, i) => ({ a, i }))
967
+ .sort((x, y) => prio(x.a) - prio(y.a) || x.i - y.i)
968
+ .map((x) => x.a);
969
+ }
970
+
971
+ // ── Auth wire selection ─────────────────────────────────────────────────────
972
+ const authToken = anon === true ? null : await ctx.getToken();
973
+ let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
974
+ if (isEdit && !ownerToken) {
975
+ // Recover the owner token from session state when re-plugging the drop this
976
+ // session made anonymously.
977
+ if (ctx.state.lastDrop?.entity_id === targetEntityId && ctx.state.lastDrop.owner_token) {
978
+ ownerToken = ctx.state.lastDrop.owner_token;
979
+ } else if (
980
+ ctx.state.lastAnonClaim?.entity_id === targetEntityId &&
981
+ ctx.state.lastAnonClaim.token
982
+ ) {
983
+ ownerToken = ctx.state.lastAnonClaim.token;
984
+ }
985
+ }
986
+ // An anon-minted (Guest-Grid) drop is edited via the owner-token wire even
987
+ // when signed in — the entity is not in the caller's grid, so an authed edit
988
+ // of it would 404. Otherwise an edit needs the authed wire.
989
+ const useAnonWire = isEdit ? Boolean(ownerToken) : !authToken;
990
+ if (isEdit && !ownerToken && !authToken) {
991
+ throw new Error(
992
+ "Re-plugging needs authorization: sign in (grid_login) for an entity in your grid, or pass the " +
993
+ "owner_token that came back when the drop was created anonymously.",
994
+ );
995
+ }
996
+
997
+ // Auth-aware inline size cap for the single-file `html` path (the old drop
998
+ // cap). Anonymous inline pages stay capped at 2 MB; signed-in inline pages get
999
+ // the larger AUTHED cap. `path` (read from disk) and `artifact_files` are
1000
+ // bounded by PLUG_MAX_TOTAL_BYTES instead, so they never set inlineHtmlBytes.
1001
+ if (inlineHtmlBytes != null) {
1002
+ if (useAnonWire) {
1003
+ if (inlineHtmlBytes > ANON_HTML_MAX_BYTES) {
1004
+ throw new Error(
1005
+ `This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
1006
+ "Trim it, or sign in to publish larger.",
1007
+ );
1008
+ }
1009
+ } else if (inlineHtmlBytes > AUTHED_HTML_MAX_BYTES) {
1010
+ throw new Error(
1011
+ `This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
1012
+ `${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
1013
+ ".html file (read from disk — no inline size limit) instead of pasting it inline.",
1014
+ );
1015
+ }
1016
+ }
1017
+
1018
+ const headers = {};
1019
+ // The authed target grid — also reused for the post-create visibility upgrade.
1020
+ let orgSlug = null;
1021
+ if (!useAnonWire && authToken) {
1022
+ headers["Authorization"] = `Bearer ${authToken}`;
1023
+ // On create, `grid` picks where the entity lands. On re-plug the entity's
1024
+ // home grid is authoritative (it never moves) — but the API still resolves
1025
+ // the caller's membership from this header and requires it to MATCH the
1026
+ // entity's grid, so pass `grid` here too when the target lives outside the
1027
+ // active grid.
1028
+ orgSlug = grid || (await ctx.getActiveGrid());
1029
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1030
+ if (orgSlug) {
1031
+ headers["X-CloudGrid-Grid"] = orgSlug;
1032
+ headers["X-CloudGrid-Org"] = orgSlug;
1033
+ }
1034
+ }
1035
+ if (useAnonWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
1036
+ headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
1037
+ headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
1038
+ }
1039
+ if (useAnonWire && ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
1040
+
1041
+ // ── CREATE manifest injection (issue #48) ───────────────────────────────────
1042
+ // On a folder-walk create, a `cloudgrid.yaml` on disk is walked into the tree
1043
+ // and — because directory reads surface it before the nested `services/…`
1044
+ // files — rides the multipart body as the FIRST `artifact` part, with the
1045
+ // walk's uniform `application/octet-stream` content-type. The runtime build
1046
+ // orchestrator relies on the manifest leading the bundle (it drives the
1047
+ // service graph + the entity name). The inline `artifact_files` create used to
1048
+ // APPEND the `cloudgrid_yaml` manifest LAST and as `text/plain`, so a
1049
+ // multi-service runtime rolled out with no service graph (0 replicas /
1050
+ // rollout_failed) and an auto `drop-XXXX` name. Fold the manifest into the
1051
+ // artifact list as the first entry (deduping any `cloudgrid.yaml` the caller
1052
+ // already inlined) so both create paths emit a byte-equivalent bundle.
1053
+ if (!isEdit && cloudgrid_yaml) {
1054
+ const manifest = { path: "cloudgrid.yaml", buffer: Buffer.from(cloudgrid_yaml, "utf8") };
1055
+ const rest = artifacts.filter((a) => a.path !== "cloudgrid.yaml");
1056
+ artifacts = [manifest, ...rest];
1057
+ }
1058
+
1059
+ // ── Wire assembly ───────────────────────────────────────────────────────────
1060
+ const form = new FormData();
1061
+ for (const a of artifacts) {
1062
+ // Folder-walk / artifact_files parts ride as octet-stream (server sniffs by
1063
+ // name); the single-file `html` path carries text/html so a bare inline page
1064
+ // renders instead of downloading (the old drop behavior).
1065
+ form.append("artifact", new Blob([a.buffer], { type: a.type || "application/octet-stream" }), a.path);
1066
+ }
1067
+ if (isEdit) {
1068
+ form.append("target_entity_id", targetEntityId);
1069
+ if (useAnonWire) {
1070
+ form.append("owner_token", ownerToken);
1071
+ } else {
1072
+ // The authed update path requires a `cloudgrid.yaml` part
1073
+ // (materializePlugTarball); an inspiration edit ignores its content.
1074
+ form.append(
1075
+ "cloudgrid.yaml",
1076
+ new Blob([cloudgrid_yaml || ""], { type: "text/plain" }),
1077
+ "cloudgrid.yaml",
1078
+ );
1079
+ }
1080
+ }
1081
+ // Honor the manifest name (issue #48): send `name:` parsed from `cloudgrid_yaml`
1082
+ // as an explicit name/slug hint so the created entity uses it instead of an
1083
+ // auto `drop-XXXX` slug. Harmless if the server owns slug generation; on a
1084
+ // re-plug the entity's name is authoritative, so only send on create.
1085
+ if (!isEdit) {
1086
+ const manifestName = parseManifestName(cloudgrid_yaml);
1087
+ if (manifestName) {
1088
+ form.append("name", manifestName);
1089
+ form.append("slug", manifestName);
1090
+ }
1091
+ }
1092
+ if (hints?.kind) {
1093
+ // `kind_hint` is what the create orchestrator reads; `hints_kind` is the
1094
+ // route's structured field on the update path. Send both — each path
1095
+ // ignores the other's.
1096
+ form.append("kind_hint", hints.kind);
1097
+ form.append("hints_kind", hints.kind);
1098
+ }
1099
+ if (hints?.yaml) form.append("hints_yaml", hints.yaml);
1100
+
1101
+ let res;
1102
+ try {
1103
+ res = await fetchImpl(`${API_BASE}/api/v2/plug`, {
1104
+ method: "POST",
1105
+ headers,
1106
+ body: form,
1107
+ signal: AbortSignal.timeout(uploadTimeoutMs),
1108
+ });
1109
+ } catch (err) {
1110
+ if (err?.name === "AbortError" || err?.name === "TimeoutError") {
1111
+ throw new Error(
1112
+ `The deploy request timed out after ${Math.round(uploadTimeoutMs / 1000)}s. ` +
1113
+ `The build may still be running on CloudGrid — check the deploy status ` +
1114
+ `(poll_url / grid_status, or your grid) before deploying again, so you don't create a duplicate.`,
1115
+ );
1116
+ }
1117
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1118
+ }
1119
+ const raw = await res.text();
1120
+ let data = null;
1121
+ try {
1122
+ data = JSON.parse(raw);
1123
+ } catch {
1124
+ /* handled below */
1125
+ }
1126
+ if (!res.ok) {
1127
+ const code = data?.error?.code;
1128
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
1129
+ const flags = {
1130
+ edition: ctx.edition,
1131
+ isEdit,
1132
+ isAnon: useAnonWire,
1133
+ signedIn: Boolean(authToken),
1134
+ };
1135
+ // Self-heal rung: a signed-in CREATE that hits the known 400 SCOPE_INVALID
1136
+ // platform bug is retried through the bundled CLI — LOCAL edition only,
1137
+ // create only (never edits), never anonymous.
1138
+ if (
1139
+ res.status === 400 &&
1140
+ code === "SCOPE_INVALID" &&
1141
+ ctx.edition === "local" &&
1142
+ !isEdit &&
1143
+ !useAnonWire &&
1144
+ authToken
1145
+ ) {
1146
+ return plugViaCliFallback(ctx, artifacts, deps);
1147
+ }
1148
+ throw new Error(plugErrorMessage(res.status, code, msg, flags));
1149
+ }
1150
+
1151
+ // Anon-session cookie continuity.
1152
+ const setCookies = res.headers.getSetCookie
1153
+ ? res.headers.getSetCookie()
1154
+ : [res.headers.get("set-cookie")].filter(Boolean);
1155
+ const anonCookie = (setCookies || [])
1156
+ .map((c) => (c || "").split(";")[0])
1157
+ .find((c) => c.startsWith("cg_anon_session="));
1158
+ if (anonCookie) ctx.state.anonCookie = anonCookie;
1159
+
1160
+ const url = resolvePlugUrl(data);
1161
+ let freshOwnerToken = typeof data.owner_token === "string" && data.owner_token.length > 0
1162
+ ? data.owner_token
1163
+ : null;
1164
+ if (!freshOwnerToken && data.claim_url) {
1165
+ try {
1166
+ freshOwnerToken = new URL(data.claim_url).searchParams.get("token");
1167
+ } catch {
1168
+ freshOwnerToken = null;
1169
+ }
1170
+ }
1171
+
1172
+ // Session continuity — remember the last plug for re-plug handles.
1173
+ if (data.entity_id || url) {
1174
+ ctx.state.lastDrop = {
1175
+ entity_id: data.entity_id ?? null,
1176
+ url: url ?? null,
1177
+ owner_token: useAnonWire ? (freshOwnerToken ?? ownerToken ?? null) : null,
1178
+ };
1179
+ }
1180
+ if (useAnonWire && (data.claim_url || freshOwnerToken)) {
1181
+ ctx.state.lastAnonClaim = {
1182
+ token: freshOwnerToken,
1183
+ entity_id: data.entity_id ?? null,
1184
+ url,
1185
+ };
1186
+ } else if (!useAnonWire) {
1187
+ ctx.state.lastAnonClaim = null;
1188
+ }
1189
+
1190
+ const structured = {
1191
+ ...(data.entity_id ? { entity_id: data.entity_id } : {}),
1192
+ ...(data.slug ? { slug: data.slug } : {}),
1193
+ grid: data.grid ?? null,
1194
+ ...(url ? { url } : {}),
1195
+ ...(data.poll_url ? { poll_url: data.poll_url } : {}),
1196
+ status: data.status ?? (isEdit ? "updated" : "created"),
1197
+ ...(data.claim_url ? { claim_url: data.claim_url } : {}),
1198
+ ...(data.claim_message ? { claim_message: data.claim_message } : {}),
1199
+ // Spec v2 omits owner_token from the output block — a spec bug (the anon
1200
+ // wire cannot re-plug without it). Included deliberately; flagged upstream.
1201
+ ...(freshOwnerToken ? { owner_token: freshOwnerToken } : {}),
1202
+ };
1203
+
1204
+ // Accurate status (issue #48): a runtime create/edit is an ASYNC build — the
1205
+ // server replies `status: "building"` (+ a poll_url) while the rollout is still
1206
+ // in flight. Do NOT claim "Live"/"Updated in place" for a build that has not
1207
+ // finished; that reported success for apps that then rolled-out-failed. Only
1208
+ // the terminal states get the live wording; anything still building points at
1209
+ // the poll_url and grid_status.
1210
+ const isBuilding = data.status === "building" || Boolean(data.poll_url);
1211
+
1212
+ // An authed CREATE of an INSPIRATION (the single-file `html` path, or a server-
1213
+ // detected inspiration — NOT a runtime app/agent, NOT a build in flight, NOT an
1214
+ // anon guest drop). This is the case that (on the hosted edition) must be made
1215
+ // link-visible so the shared URL renders without a sign-in wall.
1216
+ const detectedKind = data.detection?.kind;
1217
+ const isInspirationCreate =
1218
+ !isEdit &&
1219
+ !useAnonWire &&
1220
+ !isBuilding &&
1221
+ detectedKind !== "app" &&
1222
+ detectedKind !== "agent" &&
1223
+ (inlineHtmlBytes != null || detectedKind === "inspiration");
1224
+
1225
+ const lines = [];
1226
+ if (isBuilding) {
1227
+ lines.push(
1228
+ isEdit
1229
+ ? `Building (async): ${url} — the update is deploying, not live yet.`
1230
+ : `Building (async): ${url} — the deploy is in progress, not live yet.`,
1231
+ );
1232
+ lines.push(
1233
+ data.poll_url
1234
+ ? `Poll ${data.poll_url} or run grid_status until it is ready (trace ${data.trace_id ?? "n/a"}). Do not report it as live until then.`
1235
+ : "Run grid_status until it is ready. Do not report it as live until then.",
1236
+ );
1237
+ } else if (isEdit) {
1238
+ lines.push(`Updated in place: ${url}`);
1239
+ } else if (isInspirationCreate) {
1240
+ // Authed inspiration create — owned by the caller. Wording mirrors the drop verb.
1241
+ lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Published to your org: ${url}`);
1242
+ if (ctx.edition !== "web") lines.push("Owned by you.");
1243
+ } else {
1244
+ lines.push(`Live: ${url}`);
1245
+ }
1246
+ if (data.entity_id) {
1247
+ lines.push(
1248
+ `Re-plug handle: entity_id=${data.entity_id} — persist it (with the url) and pass it back as target_entity_id to update this entity later.`,
1249
+ );
1250
+ }
1251
+ if (data.claim_message) lines.push(data.claim_message);
1252
+ if (isEdit && useAnonWire) {
1253
+ lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
1254
+ }
1255
+
1256
+ // Visibility is the user's choice — never set silently. On a NEW deploy,
1257
+ // surface the current visibility + the full option set and have the agent ASK
1258
+ // the user, then apply their answer via grid_set_sharing. On an edit, leave the
1259
+ // entity's existing visibility untouched (don't re-ask on every re-plug).
1260
+ if (!isEdit && data.entity_id) {
1261
+ const current = typeof data.visibility === "string" ? data.visibility : null;
1262
+ structured.console_url = CONSOLE_URL;
1263
+ if (current) structured.current_visibility = current;
1264
+ structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
1265
+ lines.push(`Manage all your apps in your grid: ${CONSOLE_URL}`);
1266
+ lines.push(
1267
+ `Now ASK the user who should be able to open this${current ? ` (currently ${VISIBILITY_LABELS[current] ?? current})` : ""}, then set their choice with grid_set_sharing — do not decide it for them. Options: ${
1268
+ Object.entries(VISIBILITY_LABELS)
1269
+ .map(([v, l]) => `${v} (${l})`)
1270
+ .join("; ")
1271
+ }.`,
1272
+ );
1273
+ }
1274
+ return { text: lines.join("\n"), structured };
1275
+ }
1276
+
1277
+ // ── grid_copy_app / grid_download_source — direct-API verbs (spec v2 §5–6) ────────
1278
+
1279
+ async function authedApiCall(ctx, { method, pathName, body, verb }) {
1280
+ const token = await ctx.getToken();
1281
+ if (!token) {
1282
+ throw new Error(`${verb} requires sign-in. Run grid_login first.`);
1283
+ }
1284
+ const headers = { Authorization: `Bearer ${token}` };
1285
+ const orgSlug = await ctx.getActiveGrid();
1286
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1287
+ if (orgSlug) {
1288
+ headers["X-CloudGrid-Grid"] = orgSlug;
1289
+ headers["X-CloudGrid-Org"] = orgSlug;
1290
+ }
1291
+ if (body !== undefined) headers["Content-Type"] = "application/json";
1292
+ let res;
1293
+ try {
1294
+ res = await fetch(`${API_BASE}${pathName}`, {
1295
+ method,
1296
+ headers,
1297
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
1298
+ });
1299
+ } catch (err) {
1300
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1301
+ }
1302
+ const raw = await res.text();
1303
+ let data = null;
1304
+ try {
1305
+ data = JSON.parse(raw);
1306
+ } catch {
1307
+ /* handled below */
1308
+ }
1309
+ if (!res.ok) {
1310
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
1311
+ const codeStr = data?.error?.code || null;
1312
+ const err = new Error(`${verb} failed (HTTP ${res.status}${codeStr ? ` ${codeStr}` : ""}): ${msg}`);
1313
+ // Expose the structured status/code so callers can branch (e.g. runFork's
1314
+ // NOT_A_RUNTIME → inspiration-route fallback). Additive: existing callers
1315
+ // only read `.message`.
1316
+ err.status = res.status;
1317
+ err.code = codeStr;
1318
+ throw err;
1319
+ }
1320
+ return data;
1321
+ }
1322
+
1323
+ // Fork: start a NEW entity from an existing source, copy-on-write with lineage.
1324
+ // Kind-aware (one fork verb, mirrors the CLI `fork` command): the caller may
1325
+ // not know the source's kind, so try the runtime route first and, when the
1326
+ // server reports the target is an inspiration (`400 NOT_A_RUNTIME`, emitted by
1327
+ // requireEntityAccess before the runtime handler runs), retry the inspiration
1328
+ // route. The inspiration remix lands in the caller's active grid, so no
1329
+ // `into_org_slug`; `source_version_id` has no meaning for inspirations.
1330
+ export async function runFork(ctx, { id, into_org_slug, name, source_version_id }) {
1331
+ let data;
1332
+ try {
1333
+ data = await authedApiCall(ctx, {
1334
+ method: "POST",
1335
+ pathName: `/api/v2/runtimes/${encodeURIComponent(id)}/fork`,
1336
+ body: {
1337
+ ...(into_org_slug ? { into_org_slug } : {}),
1338
+ ...(name ? { name } : {}),
1339
+ ...(source_version_id ? { source_version_id } : {}),
1340
+ },
1341
+ verb: "Fork",
1342
+ });
1343
+ } catch (err) {
1344
+ if (err && err.code === "NOT_A_RUNTIME") {
1345
+ data = await authedApiCall(ctx, {
1346
+ method: "POST",
1347
+ pathName: `/api/v2/inspirations/${encodeURIComponent(id)}/fork`,
1348
+ body: { ...(name ? { name } : {}) },
1349
+ verb: "Fork",
1350
+ });
1351
+ } else {
1352
+ throw err;
1353
+ }
1354
+ }
1355
+ const gridSlug = data?.grid_slug ?? data?.org?.slug ?? null;
1356
+ const lines = [
1357
+ `Forked: ${data?.name ?? id} (entity_id=${data?.entity_id ?? "?"})${gridSlug ? ` in grid ${gridSlug}` : ""}`,
1358
+ `Lineage: forked_from=${data?.forked_from ?? "?"}${data?.forked_from_version_id ? ` @ ${data.forked_from_version_id}` : ""}`,
1359
+ ];
1360
+ return {
1361
+ text: lines.join("\n"),
1362
+ structured: {
1363
+ entity_id: data?.entity_id ?? null,
1364
+ name: data?.name ?? null,
1365
+ kind: data?.kind ?? null,
1366
+ grid_slug: gridSlug,
1367
+ forked_from: data?.forked_from ?? null,
1368
+ forked_from_version_id: data?.forked_from_version_id ?? null,
1369
+ current_version_id: data?.current_version_id ?? null,
1370
+ },
1371
+ };
1372
+ }
1373
+
1374
+ // Download: signed, time-limited (15-minute) source-bundle URLs. No entity is
1375
+ // created and no registry state changes.
1376
+ export async function runDownload(ctx, { id, version }) {
1377
+ const qs = version ? `?version=${encodeURIComponent(version)}` : "";
1378
+ const data = await authedApiCall(ctx, {
1379
+ method: "GET",
1380
+ pathName: `/api/v2/runtimes/${encodeURIComponent(id)}/source${qs}`,
1381
+ verb: "Download",
1382
+ });
1383
+ const services = data?.services && typeof data.services === "object" ? data.services : {};
1384
+ const lines = [`Source bundle URLs for ${data?.name ?? id} (valid ~15 minutes):`];
1385
+ for (const [svc, u] of Object.entries(services)) lines.push(` ${svc}: ${u}`);
1386
+ if (Object.keys(services).length === 0) lines.push(" (no services returned)");
1387
+ return {
1388
+ text: lines.join("\n"),
1389
+ structured: {
1390
+ entity_id: data?.entity_id ?? null,
1391
+ name: data?.name ?? null,
1392
+ services,
1393
+ domain: data?.domain ?? null,
1394
+ },
1395
+ };
1396
+ }
1397
+
1398
+ // Change an inspiration's visibility. Authed, direct API — works on the hosted
1399
+ // edition where the CLI-wrapping share tool is unavailable. Defaults to the drop
1400
+ // made in this session, so "make it private" needs no ids.
1401
+ export async function runVisibility(ctx, { target, visibility, org }) {
1402
+ const token = await ctx.getToken();
1403
+ if (!token) {
1404
+ throw new Error("Changing visibility needs an owner. Run grid_login first.");
1405
+ }
1406
+ const id = target || ctx.state.lastDrop?.entity_id;
1407
+ if (!id) {
1408
+ throw new Error("No target. Pass the entity id, or drop something first in this session.");
1409
+ }
1410
+ const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
1411
+ const orgSlug = org || (await ctx.getActiveGrid());
1412
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1413
+ if (orgSlug) {
1414
+ headers["X-CloudGrid-Grid"] = orgSlug;
1415
+ headers["X-CloudGrid-Org"] = orgSlug;
1416
+ }
1417
+ let res;
1418
+ try {
1419
+ res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(id)}`, {
1420
+ method: "PATCH",
1421
+ headers,
1422
+ body: JSON.stringify({ visibility }),
1423
+ });
1424
+ } catch (err) {
1425
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1426
+ }
1427
+ const raw = await res.text();
1428
+ let data = null;
1429
+ try { data = JSON.parse(raw); } catch { /* handled below */ }
1430
+ if (!res.ok) {
1431
+ const msg = data?.error?.message || raw || `HTTP ${res.status}`;
1432
+ const hint = data?.error?.details?.[0]?.hint;
1433
+ throw new Error(`Visibility change failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`);
1434
+ }
1435
+ const lines = [`Visibility is now ${visibility}.`];
1436
+ if (data?.url) lines.push(data.url);
1437
+ return {
1438
+ text: lines.join("\n"),
1439
+ structured: {
1440
+ visibility,
1441
+ ...(data?.url ? { url: data.url } : {}),
1442
+ },
1443
+ };
1444
+ }
1445
+
1446
+ // Max HTML we'll return inline from runSource. Past this, we return the first
1447
+ // slice with truncated:true — a re-plug needs the complete document, so a
1448
+ // truncated body is a signal the drop is likely multi-file rather than a single
1449
+ // editable HTML document.
1450
+ const SOURCE_MAX_BYTES = 1_500_000;
1451
+
1452
+ // Reject anything whose host is not `*.cloudgrid.io` (apex `cloudgrid.io`
1453
+ // included). SSRF guard for runSource: we only ever fetch live CloudGrid drops
1454
+ // server-side, never arbitrary hosts. Returns true when the host is allowed.
1455
+ function isCloudgridHost(hostname) {
1456
+ const h = String(hostname || "").toLowerCase();
1457
+ return h === "cloudgrid.io" || h.endsWith(".cloudgrid.io");
1458
+ }
1459
+
1460
+ // Shape an HTML string into the grid_get_app_source result (shared by the API-read
1461
+ // and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES. `extra` carries
1462
+ // optional edition metadata resolved from the pickup contract (kind, single_html,
1463
+ // capabilities, replug_handle, source_download_url) — merged into structured.
1464
+ function shapeSourceResult(sourceUrl, entityId, htmlStr, extra = {}) {
1465
+ const buf = Buffer.from(htmlStr, "utf-8");
1466
+ const totalBytes = buf.length;
1467
+ const truncated = totalBytes > SOURCE_MAX_BYTES;
1468
+ const html = truncated ? buf.subarray(0, SOURCE_MAX_BYTES).toString("utf-8") : htmlStr;
1469
+ const lines = [
1470
+ `Current source for ${sourceUrl} (${totalBytes} bytes) — edit this and re-plug with target_entity_id to update the same URL:`,
1471
+ ];
1472
+ if (truncated) {
1473
+ lines.push(
1474
+ "(too large to return in full; re-plug needs the complete document — consider that this drop may be multi-file)",
1475
+ );
1476
+ }
1477
+ lines.push("", html);
1478
+ return {
1479
+ text: lines.join("\n"),
1480
+ structured: { url: sourceUrl, entity_id: entityId ?? null, bytes: totalBytes, truncated, html, ...extra },
1481
+ };
1482
+ }
1483
+
1484
+ // Read an inspiration's HTML via the API (server-side storage read) instead of
1485
+ // fetching the public *.cloudgrid.io URL. Critical on the hosted edition: the
1486
+ // MCP pod can reach the API (it POSTs /api/v2/plug) but CANNOT egress to the
1487
+ // public ingress ("fetch failed"). getInspirationSource resolves directly by
1488
+ // entity_id when the path segment is a UUID (no org context needed), else by
1489
+ // slug + the X-CloudGrid-Grid active-org header. Returns the html string, or
1490
+ // null if the API can't serve it (→ caller falls back to the direct fetch).
1491
+ async function readInspirationSourceViaApi(ctx, { entityId, slug, grid }) {
1492
+ let token = null;
1493
+ try { token = await ctx.getToken(); } catch { /* anonymous is fine for public */ }
1494
+ const baseHeaders = token ? { Authorization: `Bearer ${token}` } : {};
1495
+ const attempts = [];
1496
+ if (entityId) attempts.push({ seg: entityId, grid: null }); // UUID → resolves by id, no org ctx
1497
+ if (slug) attempts.push({ seg: slug, grid: grid || null }); // slug → needs org (grid) context
1498
+ for (const a of attempts) {
1499
+ try {
1500
+ const headers = { ...baseHeaders };
1501
+ if (a.grid) headers["X-CloudGrid-Grid"] = a.grid;
1502
+ const res = await fetch(
1503
+ `${API_BASE}/api/v2/inspirations/${encodeURIComponent(a.seg)}/source`,
1504
+ { method: "GET", headers, signal: AbortSignal.timeout(15_000) },
1505
+ );
1506
+ if (!res.ok) continue;
1507
+ // The route (GET /v2/inspirations/:seg/source) serves the RAW HTML bytes
1508
+ // as text/html — NOT a JSON { html } envelope. Read the body as text.
1509
+ // (Tolerate a JSON { html } shape too, in case a variant ever returns it.)
1510
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
1511
+ if (ct.includes("application/json")) {
1512
+ const data = await res.json().catch(() => null);
1513
+ if (data && typeof data.html === "string") return data.html;
1514
+ continue;
1515
+ }
1516
+ const html = await res.text().catch(() => null);
1517
+ if (typeof html === "string" && html.length > 0) return html;
1518
+ } catch {
1519
+ // try the next attempt, then the public-fetch fallback
1520
+ }
1521
+ }
1522
+ return null;
1523
+ }
1524
+
1525
+ // Resolve a public URL / grid+slug to a REAL entity_id (+ edition metadata) via
1526
+ // the deployed pickup contract (POST /api/v2/entities/:target/pickup — the same
1527
+ // endpoint runClaim uses). Used by runSource when a bare URL arrives with no
1528
+ // session entity_id (a fresh chat): the contract returns
1529
+ // { entity_id, slug, grid, kind, single_html, capabilities, replug_handle,
1530
+ // source_download_url, ... }
1531
+ // so the agent can re-plug the SAME entity in place. This is a metadata resolve
1532
+ // (no claim_token in the body → no ownership transfer). Best-effort: returns null
1533
+ // on any failure so the caller falls back to today's behavior — and NEVER fetches
1534
+ // the public *.cloudgrid.io URL (the hosted pod cannot egress to it).
1535
+ async function resolveEntityViaPickup(ctx, { target, url, grid }) {
1536
+ const pathSeg = target || url;
1537
+ if (!pathSeg) return null;
1538
+ let token = null;
1539
+ try { token = await ctx.getToken(); } catch { /* anonymous is fine */ }
1540
+ const headers = { "Content-Type": "application/json" };
1541
+ if (token) headers["Authorization"] = `Bearer ${token}`;
1542
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1543
+ if (grid) {
1544
+ headers["X-CloudGrid-Grid"] = grid;
1545
+ headers["X-CloudGrid-Org"] = grid;
1546
+ }
1547
+ if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
1548
+ try {
1549
+ const res = await fetch(
1550
+ `${API_BASE}/api/v2/entities/${encodeURIComponent(pathSeg)}/pickup`,
1551
+ {
1552
+ method: "POST",
1553
+ headers,
1554
+ // Send the URL as a resolution key too (the contract accepts id, slug, or
1555
+ // a {url} body). NO claim_token → resolve only, never a claim.
1556
+ body: JSON.stringify(url ? { url } : {}),
1557
+ signal: AbortSignal.timeout(15_000),
1558
+ },
1559
+ );
1560
+ if (!res.ok) return null;
1561
+ const data = await res.json().catch(() => null);
1562
+ if (data && typeof data.entity_id === "string" && data.entity_id.length > 0) return data;
1563
+ return null;
1564
+ } catch {
1565
+ return null;
1566
+ }
1567
+ }
1568
+
1569
+ // Fetch a drop's/inspiration's current deployed HTML inline as text so an agent
1570
+ // that lost the content can edit it and re-plug in place. Resolves the fetch URL
1571
+ // (explicit url → session lastDrop → composed grid+slug), then reads the HTML
1572
+ // via the API (reachable) BEFORE falling back to a direct `*.cloudgrid.io` fetch
1573
+ // (SSRF-guarded, capped at SOURCE_MAX_BYTES). Uses the global `fetch` seam so
1574
+ // tests can mock it.
1575
+ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
1576
+ const last = ctx.state.lastDrop;
1577
+ // Resolution order: explicit url → session lastDrop.url (if entity_id matches
1578
+ // or no entity_id was given) → composePlugUrl(grid, slug) → fail.
1579
+ let target = null;
1580
+ if (typeof url === "string" && url.length > 0) {
1581
+ target = url;
1582
+ } else if (last?.url && (!entity_id || last.entity_id === entity_id)) {
1583
+ target = last.url;
1584
+ } else if (grid && slug) {
1585
+ // Inspirations/HTML drops are path-based at the org apex.
1586
+ target = composePlugUrl({ slug, grid });
1587
+ }
1588
+ if (!target) {
1589
+ throw new Error(
1590
+ "I don't have this drop's URL — pass the url (e.g. https://<grid>.cloudgrid.io/<slug>) or grid+slug.",
1591
+ );
1592
+ }
1593
+
1594
+ let parsed;
1595
+ try {
1596
+ parsed = new URL(target);
1597
+ } catch {
1598
+ throw new Error(`Not a valid URL: ${target}`);
1599
+ }
1600
+ if (parsed.protocol !== "https:" || !isCloudgridHost(parsed.hostname)) {
1601
+ throw new Error(
1602
+ `Refusing to fetch ${target}: source retrieval is limited to https://*.cloudgrid.io drops.`,
1603
+ );
1604
+ }
1605
+
1606
+ const resolvedUrl = parsed.toString();
1607
+ let eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
1608
+
1609
+ // Derive the grid/slug hint from the inspiration path URL (`grid.cloudgrid.io/slug`).
1610
+ const host = parsed.hostname;
1611
+ const gridHint = grid ?? (host.endsWith(".cloudgrid.io") && !host.includes("--") && host.split(".").length === 3
1612
+ ? host.split(".")[0] : null);
1613
+ const slugHint = slug ?? (parsed.pathname.replace(/^\/+/, "").split("/")[0] || null);
1614
+
1615
+ // ── URL → entity_id (fresh chat, no session) via the pickup contract ──────
1616
+ // When a bare URL/slug arrived with no known entity_id, resolve a REAL
1617
+ // entity_id (+ edition metadata) so the agent can re-plug in place. Best-
1618
+ // effort: a failure falls back to today's behavior (never regress the read),
1619
+ // and we NEVER fetch the public URL for resolution.
1620
+ let extra = {};
1621
+ if (!eid) {
1622
+ const pickup = await resolveEntityViaPickup(ctx, { target: slugHint, url: resolvedUrl, grid: gridHint });
1623
+ if (pickup?.entity_id) {
1624
+ eid = pickup.entity_id;
1625
+ extra = {
1626
+ ...(pickup.kind ? { kind: pickup.kind } : {}),
1627
+ ...(typeof pickup.single_html === "boolean" ? { single_html: pickup.single_html } : {}),
1628
+ ...(pickup.capabilities ? { capabilities: pickup.capabilities } : {}),
1629
+ ...(pickup.replug_handle ? { replug_handle: pickup.replug_handle } : {}),
1630
+ ...(pickup.source_download_url ? { source_download_url: pickup.source_download_url } : {}),
1631
+ };
1632
+ }
1633
+ }
1634
+
1635
+ // ── API-first (reachable) ────────────────────────────────────────────────
1636
+ // Read the HTML from the API server-side rather than fetching the public URL.
1637
+ // The hosted MCP pod can reach the API but NOT the public *.cloudgrid.io
1638
+ // ingress ("fetch failed") — this is the fix for hosted edit-in-place.
1639
+ const apiHtml = await readInspirationSourceViaApi(ctx, { entityId: eid, slug: slugHint, grid: gridHint });
1640
+ if (apiHtml != null) return shapeSourceResult(resolvedUrl, eid, apiHtml, extra);
1641
+
1642
+ // ── Fallback: direct public fetch ────────────────────────────────────────
1643
+ // Local edition has normal egress; last resort on hosted (e.g. a runtime app
1644
+ // whose source the inspiration route can't serve).
1645
+ let res;
1646
+ try {
1647
+ res = await fetch(resolvedUrl, {
1648
+ method: "GET",
1649
+ redirect: "follow",
1650
+ signal: AbortSignal.timeout(15_000),
1651
+ });
1652
+ } catch (err) {
1653
+ throw new Error(`Could not reach the live drop at ${resolvedUrl}: ${err.message}`);
1654
+ }
1655
+
1656
+ // A redirect must not escape the allow-list (fetch follows automatically; the
1657
+ // final response URL is the one we actually read).
1658
+ if (res.url && res.url !== resolvedUrl) {
1659
+ let finalHost;
1660
+ try { finalHost = new URL(res.url).hostname; } catch { finalHost = ""; }
1661
+ if (!isCloudgridHost(finalHost)) {
1662
+ throw new Error(
1663
+ `Refusing to follow a redirect off CloudGrid (${res.url}): source retrieval is limited to https://*.cloudgrid.io.`,
1664
+ );
1665
+ }
1666
+ }
1667
+
1668
+ if (!res.ok) {
1669
+ // Graceful fail — never throw a raw fetch error at the model.
1670
+ throw new Error(
1671
+ `Couldn't read the live drop (HTTP ${res.status}). It may be expired, private, or claimed.`,
1672
+ );
1673
+ }
1674
+
1675
+ const buf = Buffer.from(await res.arrayBuffer());
1676
+ return shapeSourceResult(resolvedUrl, eid, buf.toString("utf-8"), extra);
1677
+ }