@cloudgrid-io/mcp 0.20.7 → 0.20.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1891 @@
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
+ // ── Zip deploys (local edition) ──────────────────────────────────────────────
677
+ // "Build me a gallery site with the attached images (zip)" — the local server
678
+ // CAN open archives even though the model cannot. `path` accepts a .zip: it is
679
+ // extracted to a temp dir and deployed. Because the platform's multi-file
680
+ // INSPIRATION create currently persists only the primary HTML (see the
681
+ // inline-create issue filed 2026-07-17), a multi-file zip is ALWAYS shaped as
682
+ // a static RUNTIME project — a synthesized cloudgrid.yaml (type: static) with
683
+ // the files under services/web/ — which the server builds and serves fully.
684
+ // A zip that ships its own cloudgrid.yaml is deployed as-is.
685
+ //
686
+ // `html` is allowed TOGETHER with a zip path (the one source combo): it becomes
687
+ // services/web/index.html, so an agent on a no-filesystem client can generate
688
+ // the page inline while the archive supplies the assets.
689
+
690
+ function isZipPath(srcPath) {
691
+ const abs = resolve(srcPath);
692
+ if (!existsSync(abs) || !statSync(abs).isFile()) return false;
693
+ if (/\.zip$/i.test(abs)) return true;
694
+ // Magic sniff so "photos.ZIP.download"-style names still work.
695
+ try {
696
+ const fd = readFileSync(abs);
697
+ return fd.length >= 4 && fd[0] === 0x50 && fd[1] === 0x4b && fd[2] === 0x03 && fd[3] === 0x04;
698
+ } catch {
699
+ return false;
700
+ }
701
+ }
702
+
703
+ // Extract a zip safely and return the directory to deploy. Throws on traversal.
704
+ async function expandZipToProject(zipPath, inlineHtml) {
705
+ const { unzipSync } = await import("fflate");
706
+ const raw = readFileSync(resolve(zipPath));
707
+ if (raw.byteLength > PLUG_MAX_TOTAL_BYTES) {
708
+ throw new Error("The zip exceeds the 100MB plug limit. Trim it or split the content.");
709
+ }
710
+ let entries;
711
+ try {
712
+ entries = unzipSync(new Uint8Array(raw));
713
+ } catch (err) {
714
+ throw new Error(`Could not read the zip archive: ${err.message}`);
715
+ }
716
+ // Sanitize: reject traversal, skip macOS metadata + always-skip dirs.
717
+ const files = [];
718
+ for (const [name, data] of Object.entries(entries)) {
719
+ if (name.endsWith("/")) continue; // directory entry
720
+ const norm = name.replace(/\\/g, "/");
721
+ if (norm.startsWith("/") || /^[A-Za-z]:/.test(norm) || norm.split("/").includes("..")) {
722
+ throw new Error(`Refusing zip entry outside the archive root: ${name}`);
723
+ }
724
+ const parts = norm.split("/");
725
+ if (parts.includes("__MACOSX") || parts.some((p) => PLUG_ALWAYS_SKIP.has(p))) continue;
726
+ files.push({ path: norm, data });
727
+ }
728
+ if (files.length === 0) throw new Error("The zip contains no deployable files.");
729
+ if (files.length > PLUG_MAX_FILES) {
730
+ throw new Error(`The zip has more than ${PLUG_MAX_FILES} files — too large to plug.`);
731
+ }
732
+ // Strip a single common root folder (the usual zip-of-a-folder shape) — but
733
+ // ONLY when stripping surfaces an index.html or cloudgrid.yaml at the root.
734
+ // An assets-only archive like img/a.png shares a common root too, and
735
+ // flattening it would break the page's relative img/ references.
736
+ const firstSeg = files[0].path.split("/")[0];
737
+ const singleRoot =
738
+ files.every((f) => f.path.split("/")[0] === firstSeg) && files.every((f) => f.path.includes("/"));
739
+ if (singleRoot) {
740
+ const stripped = files.map((f) => f.path.slice(firstSeg.length + 1));
741
+ if (stripped.some((p) => /^index\.html?$/i.test(p) || p === "cloudgrid.yaml")) {
742
+ files.forEach((f, i) => { f.path = stripped[i]; });
743
+ if (files.some((f) => !f.path)) throw new Error("Unexpected zip layout (empty path after root strip).");
744
+ }
745
+ }
746
+
747
+ const hasManifest = files.some((f) => f.path === "cloudgrid.yaml");
748
+ const hasIndex = files.some((f) => /^index\.html?$/i.test(f.path));
749
+ const hasInlineHtml = typeof inlineHtml === "string" && inlineHtml.length > 0;
750
+ if (hasInlineHtml && hasManifest) {
751
+ throw new Error(
752
+ "The zip already contains a cloudgrid.yaml project — deploy it as-is (drop the `html` param), " +
753
+ "or re-plug the entity and edit its files instead.",
754
+ );
755
+ }
756
+ if (hasInlineHtml && hasIndex) {
757
+ throw new Error(
758
+ "The zip already has an index.html — pass either the zip alone, or `html` with a zip of assets only.",
759
+ );
760
+ }
761
+ if (!hasManifest && !hasIndex && !hasInlineHtml) {
762
+ throw new Error(
763
+ "The zip has no index.html and no cloudgrid.yaml. Generate the page first and pass it as `html` " +
764
+ "alongside the zip (the archive then supplies the assets), or add an index.html to the archive.",
765
+ );
766
+ }
767
+
768
+ // A zip that is JUST one HTML page (no manifest, no assets) rides the
769
+ // instant single-file inspiration path instead of a runtime build.
770
+ if (!hasManifest && !hasInlineHtml && files.length === 1 && /^index\.html?$/i.test(files[0].path)) {
771
+ return { singleHtml: Buffer.from(files[0].data).toString("utf8") };
772
+ }
773
+
774
+ const dir = await mkdtemp(join(tmpdir(), "cloudgrid-zip-"));
775
+ const writeAll = async (base) => {
776
+ for (const f of files) {
777
+ const dest = resolve(join(base, f.path));
778
+ if (dest !== base && !dest.startsWith(base + sep)) {
779
+ throw new Error(`Refusing to write zip entry outside the temp dir: ${f.path}`);
780
+ }
781
+ await mkdir(dirname(dest), { recursive: true });
782
+ await writeFile(dest, f.data);
783
+ }
784
+ };
785
+
786
+ if (hasManifest) {
787
+ // The archive is already a CloudGrid project — deploy verbatim.
788
+ await writeAll(resolve(dir));
789
+ return { projectDir: dir, name: null };
790
+ }
791
+
792
+ // Synthesize a static-runtime wrapper: name from the archive, files under
793
+ // services/web/. (Static RUNTIME, not inspiration, so every file survives.)
794
+ const rawName = basename(resolve(zipPath)).replace(/\.zip$/i, "");
795
+ const slugged = rawName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 42);
796
+ const name = slugged.length >= 2 && /^[a-z0-9]/.test(slugged) ? slugged : "zip-site";
797
+ const webDir = resolve(join(dir, "services", "web"));
798
+ await mkdir(webDir, { recursive: true });
799
+ await writeAll(webDir);
800
+ if (hasInlineHtml) {
801
+ const art = htmlToArtifact(inlineHtml);
802
+ await writeFile(join(webDir, "index.html"), art.buffer);
803
+ }
804
+ await writeFile(
805
+ join(dir, "cloudgrid.yaml"),
806
+ `name: ${name}\ndescription: Deployed from ${basename(zipPath)}.\nservices:\n web:\n type: static\n path: /\n`,
807
+ );
808
+ return { projectDir: dir, name };
809
+ }
810
+
811
+ // Deploy an extracted zip PROJECT through the CLI (`grid init --here` +
812
+ // `grid plug`). The direct-API inline wire cannot be used here: verified live
813
+ // 2026-07-17, it drops secondary files on inspiration creates AND never starts
814
+ // the build for runtime creates from path-mode ("charged, not yet live" —
815
+ // entities drop-df1f / drop-e86d). The CLI plug path builds correctly (every
816
+ // runtime this week shipped through it), so zip projects ride it.
817
+ async function plugZipProjectViaCli(ctx, { projectDir, name }, input, deps = {}) {
818
+ const { cliRun = runCloudgrid } = deps;
819
+ const grid = input?.grid || (await ctx.getActiveGrid?.()) || null;
820
+ const manifestPath = join(projectDir, "cloudgrid.yaml");
821
+ const manifestBody = readFileSync(manifestPath, "utf8");
822
+ const yamlName =
823
+ name ||
824
+ (() => {
825
+ const m = /^name:\s*(.+?)\s*$/m.exec(manifestBody);
826
+ return m ? m[1].replace(/^["']|["']$/g, "") : "zip-site";
827
+ })();
828
+ // `grid init --here` refuses a dir that already has a cloudgrid.yaml, and it
829
+ // is also the step that mints the real slug (name + 4-hex suffix). So: stash
830
+ // the manifest, init bare, then restore it with the assigned slug as name:.
831
+ await rm(manifestPath, { force: true });
832
+ const initArgs = ["init", "app", yamlName, "--here", ...(grid ? ["--grid", grid] : [])];
833
+ const initOut = String((await cliRun(initArgs, { cwd: projectDir })) || "");
834
+ let assignedSlug = /Slug:\s+(\S+)/.exec(initOut)?.[1] ?? null;
835
+ if (!assignedSlug) {
836
+ try {
837
+ assignedSlug = JSON.parse(readFileSync(join(projectDir, ".cloudgrid", "link.json"), "utf8")).entity_slug;
838
+ } catch {
839
+ assignedSlug = yamlName;
840
+ }
841
+ }
842
+ await writeFile(manifestPath, manifestBody.replace(/^name:.*$/m, `name: ${assignedSlug}`));
843
+ const stdout = await cliRun(
844
+ ["plug", "--no-progress", "--no-clipboard", "--no-notify"],
845
+ { cwd: projectDir },
846
+ );
847
+ const url = parseCliPlugUrl(String(stdout || ""));
848
+ if (!url) {
849
+ throw new Error(
850
+ `The zip project deployed via the CLI but no live URL was found in its output.\n${String(stdout || "").slice(0, 500)}`,
851
+ );
852
+ }
853
+ return {
854
+ text:
855
+ `Live: ${url}\n` +
856
+ "(Deployed from the zip archive as a static app via the bundled CloudGrid CLI.)",
857
+ structured: { url, status: "created", via: "zip-cli" },
858
+ };
859
+ }
860
+
861
+ // Walk a local folder into `[{path, buffer}]` artifacts (repo-relative paths),
862
+ // honoring .gitignore/.cloudgridignore at the root plus the always-skip set.
863
+ // A single file becomes one artifact named by its basename.
864
+ function collectPathArtifacts(srcPath) {
865
+ const abs = resolve(srcPath);
866
+ if (!existsSync(abs)) throw new Error(`Path does not exist: ${abs}`);
867
+ const st = statSync(abs);
868
+ if (st.isFile()) {
869
+ return [{ path: basename(abs), buffer: readFileSync(abs) }];
870
+ }
871
+ if (!st.isDirectory()) throw new Error(`Not a file or directory: ${abs}`);
872
+ const isIgnored = loadIgnoreMatchers(abs);
873
+ const out = [];
874
+ let total = 0;
875
+ const walk = (dir, rel) => {
876
+ for (const nm of readdirSync(dir)) {
877
+ if (PLUG_ALWAYS_SKIP.has(nm)) continue;
878
+ const childAbs = join(dir, nm);
879
+ const childRel = rel ? `${rel}/${nm}` : nm;
880
+ let cst;
881
+ try {
882
+ cst = statSync(childAbs);
883
+ } catch {
884
+ continue; // broken symlink etc.
885
+ }
886
+ if (isIgnored(childRel, cst.isDirectory())) continue;
887
+ if (cst.isDirectory()) {
888
+ walk(childAbs, childRel);
889
+ } else if (cst.isFile()) {
890
+ if (out.length >= PLUG_MAX_FILES) {
891
+ throw new Error(
892
+ `The folder has more than ${PLUG_MAX_FILES} files after ignores — too large to plug inline. Trim it or add a .cloudgridignore.`,
893
+ );
894
+ }
895
+ total += cst.size;
896
+ if (total > PLUG_MAX_TOTAL_BYTES) {
897
+ throw new Error("The upload exceeds the 100MB plug limit. Trim the folder or add a .cloudgridignore.");
898
+ }
899
+ out.push({ path: childRel, buffer: readFileSync(childAbs) });
900
+ }
901
+ }
902
+ };
903
+ walk(abs, "");
904
+ if (out.length === 0) throw new Error(`Nothing to upload in ${abs} (everything ignored or empty).`);
905
+ return out;
906
+ }
907
+
908
+ // ── Self-healing error guidance (Task 31 / 0.7.2) ────────────────────────────
909
+ // Map a KNOWN failure code to a short, agent-facing next-step sentence appended
910
+ // to the raw server error. Returns null for anything unknown — callers MUST let
911
+ // unknown errors pass through UNCHANGED (no blanket rewriting). Pure and
912
+ // exported so the unit tests can assert the mapping directly.
913
+ //
914
+ // Context flags:
915
+ // edition — "local" | "web"; steers the SCOPE_INVALID wording (the local
916
+ // edition self-heals via the bundled CLI; the web edition cannot).
917
+ // isEdit — a re-plug (target_entity_id present) vs a create.
918
+ // isAnon — the call already rode the anonymous wire.
919
+ // signedIn — the caller has a usable auth token (steers the 429 wording).
920
+ export function errorGuidance({ status, code, edition, isEdit, isAnon, signedIn } = {}) {
921
+ // 400 SCOPE_INVALID — the known platform bug: the /plug create branch ignores
922
+ // scope/visibility on a signed-in create and 400s (scope=personal,
923
+ // visibility=grid). It does NOT affect re-plug of an existing entity.
924
+ if (status === 400 && code === "SCOPE_INVALID") {
925
+ // Anonymous creates don't hit this branch; if one somehow reports it, there
926
+ // is no self-heal path — say nothing edition-specific.
927
+ if (isAnon) return null;
928
+ if (isEdit) {
929
+ // A re-plug that 400s here is not the create bug — no special guidance.
930
+ return null;
931
+ }
932
+ if (edition === "local") {
933
+ return "Known platform issue with signed-in creates via the plug API. Falling back to the bundled CloudGrid CLI…";
934
+ }
935
+ // web (and any non-local edition): no CLI to fall back to.
936
+ return (
937
+ "Known platform issue with signed-in creates via the plug API. " +
938
+ "Re-plug of an existing entity still works; creating new entities is temporarily affected — " +
939
+ "do NOT retry with other parameters and do NOT fall back to anonymous."
940
+ );
941
+ }
942
+ // 429 — the daily anonymous cap. Never a sign-in problem; do not loop on login.
943
+ if (status === 429) {
944
+ return (
945
+ "Do not retry today and do not treat this as a sign-in problem. " +
946
+ "If the user is signed in, use the signed-in path instead of anonymous."
947
+ );
948
+ }
949
+ // 409 EDIT_REJECTED — an in-place re-plug the server won't take.
950
+ if (status === 409) {
951
+ 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.";
952
+ }
953
+ // 401 on an edit — the credential didn't authorize this entity.
954
+ if (status === 401) {
955
+ return isEdit
956
+ ? "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."
957
+ : "Sign in (grid_login), or for an anonymously-created drop pass its owner_token.";
958
+ }
959
+ if (status === 403) {
960
+ 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).";
961
+ }
962
+ // ── Consent-gated report offer (Task 34) ──────────────────────────────────
963
+ // GENUINE bugs only: a build/deploy failure, any 5xx, INTERNAL_ERROR, or an
964
+ // unknown/unmapped error. Everything above (429 rate-limit, needs_grid picker,
965
+ // 401 sign-in prompts, 409 EDIT_REJECTED, 403) is an EXPECTED condition, not a
966
+ // bug — those returned already and never reach here, so they never get the
967
+ // offer. The offer tells the agent to ask permission first and never send the
968
+ // full conversation without an explicit yes.
969
+ const isServerError = typeof status === "number" && status >= 500;
970
+ const isInternalError = code === "INTERNAL_ERROR";
971
+ const isBuildFailure = code === "BUILD_FAILED" || code === "DEPLOY_FAILED";
972
+ if (isServerError || isInternalError || isBuildFailure) {
973
+ return REPORT_OFFER;
974
+ }
975
+ // A 4xx with an unknown/unmapped code is a client-side condition (validation,
976
+ // bad input), not a server bug — pass through unchanged so callers don't
977
+ // rewrite it or wrongly offer to report it.
978
+ return null;
979
+ }
980
+
981
+ // The consent-gated report affordance appended to genuine-bug guidance. It
982
+ // instructs the agent to get explicit permission before calling grid_report,
983
+ // and to never send the whole conversation without an explicit yes.
984
+ export const REPORT_OFFER =
985
+ "If this looks like a CloudGrid bug, ASK the user for permission to report it to the CloudGrid team, " +
986
+ "then call grid_report with the error + the failed request context. " +
987
+ "Do NOT report without an explicit yes, and do NOT include the full conversation unless the user explicitly agrees.";
988
+
989
+ // Map friendly plug error statuses to actionable messages (spec v2 §3.3).
990
+ // Appends errorGuidance() for known codes; unknown codes pass through as the
991
+ // bare `base` line, unchanged.
992
+ function plugErrorMessage(status, code, msg, ctxFlags = {}) {
993
+ const base = `Plug failed (HTTP ${status}${code ? ` ${code}` : ""}): ${msg}`;
994
+ const guidance = errorGuidance({ status, code, ...ctxFlags });
995
+ return guidance ? `${base} — ${guidance}` : base;
996
+ }
997
+
998
+ // Parse the live URL the CLI prints on a successful `plug`. The CLI prints the
999
+ // canonical https://…cloudgrid.io URL somewhere in stdout (labelled "Outlet"
1000
+ // / "Live" / bare); take the last cloudgrid.io URL it emits — the final line is
1001
+ // the deployed URL, not an intermediate build/log link.
1002
+ export function parseCliPlugUrl(stdout) {
1003
+ const matches = String(stdout || "").match(/https?:\/\/[^\s'"<>)\]]*cloudgrid\.io[^\s'"<>)\]]*/g);
1004
+ if (!matches || matches.length === 0) return null;
1005
+ return matches[matches.length - 1].replace(/[.,;]+$/, "");
1006
+ }
1007
+
1008
+ // LOCAL-EDITION SELF-HEAL RUNG (Task 31). When a signed-in CREATE via /plug hits
1009
+ // the known 400 SCOPE_INVALID platform bug, re-run the create through the
1010
+ // bundled CloudGrid CLI (whose wire is unaffected). Writes the in-memory
1011
+ // artifacts to a temp dir, runs `plug <dir> --no-clipboard --no-notify`, parses
1012
+ // the live URL, and returns a normal runPlug-shaped success. Always cleans up
1013
+ // the temp dir. The `run` dep is a seam for tests (defaults to runCloudgrid).
1014
+ //
1015
+ // Caller MUST gate this: local edition, create only (never edits), signed-in
1016
+ // (never anonymous), and only for the SCOPE_INVALID failure.
1017
+ export async function plugViaCliFallback(ctx, artifacts, deps = {}) {
1018
+ const { run = runCloudgrid, makeTmp = () => mkdtemp(join(tmpdir(), "cloudgrid-plug-")) } = deps;
1019
+ const dir = await makeTmp();
1020
+ try {
1021
+ const root = resolve(dir);
1022
+ for (const a of artifacts) {
1023
+ const dest = resolve(root, a.path);
1024
+ // Containment guard: never let an artifact path escape the temp dir.
1025
+ if (dest !== root && !dest.startsWith(root + sep)) {
1026
+ throw new Error(`Refusing to write artifact outside the temp dir: ${a.path}`);
1027
+ }
1028
+ await mkdir(dirname(dest), { recursive: true });
1029
+ await writeFile(dest, a.buffer);
1030
+ }
1031
+ const stdout = await run(["plug", dir, "--no-clipboard", "--no-notify"]);
1032
+ const url = parseCliPlugUrl(stdout);
1033
+ if (!url) {
1034
+ throw new Error(
1035
+ `CLI fallback ran but no live URL was found in its output.\n${String(stdout || "").slice(0, 500)}`,
1036
+ );
1037
+ }
1038
+ // The CLI created the entity; keep session continuity loosely (no entity_id
1039
+ // is parsed from stdout, so a later re-plug rides the create path again).
1040
+ ctx.state.lastAnonClaim = null;
1041
+ return {
1042
+ text:
1043
+ `Live: ${url}\n` +
1044
+ "(Recovered via the bundled CloudGrid CLI — the signed-in plug API hit a known platform issue, so the CLI published this instead.)",
1045
+ structured: { url, status: "created", via: "cli-fallback" },
1046
+ };
1047
+ } finally {
1048
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * The unified create/re-plug verb (spec v2 §3). Two intents on one tool, keyed
1054
+ * by `target_entity_id`:
1055
+ * - absent → CREATE: mint a new entity from the artifact (server detection
1056
+ * decides the kind unless hinted). Authed → the caller's grid; anon → a
1057
+ * Guest-Grid drop with a claim_url + owner_token.
1058
+ * - present → RE-PLUG: update the SAME entity in place (same id, slug, URL,
1059
+ * history). Authed for entities in your grid; `owner_token` for a drop
1060
+ * minted anonymously (anon owner-token contract).
1061
+ * Source is `path` (local edition — the folder/file is read and uploaded) XOR
1062
+ * `artifact_files` (hosted — inline file entries).
1063
+ */
1064
+ export async function runPlug(ctx, input, deps = {}) {
1065
+ const { fetchImpl = fetch, uploadTimeoutMs = PLUG_UPLOAD_TIMEOUT_MS } = deps;
1066
+ const {
1067
+ path: srcPath,
1068
+ artifact_files,
1069
+ html,
1070
+ filename,
1071
+ cloudgrid_yaml,
1072
+ target_entity_id,
1073
+ grid,
1074
+ slug,
1075
+ hints,
1076
+ anon,
1077
+ owner_token,
1078
+ } = input || {};
1079
+
1080
+ // ── Source: exactly one of html | artifact_files | path ─────────────────────
1081
+ // One allowed combo: `html` + a `path` that is a ZIP archive — the html
1082
+ // becomes index.html and the archive supplies the assets (the Desktop
1083
+ // "gallery from a zip" flow, where the model can generate a page but cannot
1084
+ // write files).
1085
+ let hasHtml = typeof html === "string" && html.length > 0;
1086
+ const hasArtifacts = Array.isArray(artifact_files) && artifact_files.length > 0;
1087
+ let hasPath = Boolean(srcPath);
1088
+ let effectivePath = srcPath;
1089
+ const zipSource = hasPath && ctx.edition !== "web" && isZipPath(srcPath);
1090
+ if ((hasHtml ? 1 : 0) + (hasArtifacts ? 1 : 0) + (hasPath ? 1 : 0) > 1 && !(zipSource && hasHtml && !hasArtifacts)) {
1091
+ throw new Error(
1092
+ "Pass exactly one source: `html` (a single inline HTML document), `artifact_files` " +
1093
+ "(multiple inline files), or `path` (a local file/folder/zip). Exception: `html` " +
1094
+ "may accompany a .zip `path` — it becomes the index.html over the archive's assets.",
1095
+ );
1096
+ }
1097
+ if (ctx.edition === "web" && hasPath) {
1098
+ throw new Error(
1099
+ "The hosted server cannot read local files — pass the source inline via `html` or `artifact_files`.",
1100
+ );
1101
+ }
1102
+ let zipSingleHtml = null;
1103
+ if (zipSource) {
1104
+ if (target_entity_id || (slug && grid)) {
1105
+ throw new Error(
1106
+ "Re-plugging an existing entity from a zip is not supported yet — pick up the app " +
1107
+ "(grid_edit_existing_app) and re-plug the folder, or deploy the zip as a new entity.",
1108
+ );
1109
+ }
1110
+ if (anon) {
1111
+ throw new Error("A zip deploy creates a static app and needs sign-in — it cannot be anonymous.");
1112
+ }
1113
+ const expanded = await expandZipToProject(srcPath, hasHtml ? html : null);
1114
+ if (expanded.singleHtml) {
1115
+ // One-page archive → instant inspiration via the proven html wire.
1116
+ zipSingleHtml = expanded.singleHtml;
1117
+ hasPath = false;
1118
+ hasHtml = true;
1119
+ } else {
1120
+ // Multi-file / project archive → CLI deploy (see plugZipProjectViaCli).
1121
+ return plugZipProjectViaCli(ctx, expanded, input, deps);
1122
+ }
1123
+ }
1124
+ let artifacts;
1125
+ // Set on the single-file `html` path so the auth-aware inline size cap can be
1126
+ // enforced once the anon-vs-authed wire is known (see below).
1127
+ let inlineHtmlBytes = null;
1128
+ if (hasHtml) {
1129
+ // The ergonomic single-file publish path (the old drop verb): one self-
1130
+ // contained HTML document → one index.html artifact, with the shared
1131
+ // hardening (base64 rescue, @-path/file-path rejection, fragment wrap).
1132
+ const art = htmlToArtifact(zipSingleHtml ?? html, filename);
1133
+ inlineHtmlBytes = art.buffer.byteLength;
1134
+ artifacts = [art];
1135
+ } else if (hasPath) {
1136
+ artifacts = collectPathArtifacts(effectivePath);
1137
+ } else if (hasArtifacts) {
1138
+ let total = 0;
1139
+ artifacts = artifact_files.map((f) => {
1140
+ if (!f || typeof f.path !== "string" || typeof f.content !== "string") {
1141
+ throw new Error("Each artifact_files entry needs `path` and `content`.");
1142
+ }
1143
+ const buffer = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
1144
+ total += buffer.byteLength;
1145
+ if (total > PLUG_MAX_TOTAL_BYTES) {
1146
+ throw new Error("The upload exceeds the 100MB plug limit.");
1147
+ }
1148
+ return { path: f.path, buffer };
1149
+ });
1150
+ } else {
1151
+ throw new Error(
1152
+ ctx.edition === "web"
1153
+ ? "Provide the source via `html` (a single inline HTML document) or `artifact_files`."
1154
+ : "Provide the source via `html` (a single inline HTML document), `path` (a local file " +
1155
+ "or folder), or `artifact_files`.",
1156
+ );
1157
+ }
1158
+
1159
+ // grid+slug re-plug handle (the pickup contract's `replug_handle`): when no
1160
+ // explicit target_entity_id was given but a grid+slug pair is, resolve it to
1161
+ // an existing entity_id and re-plug that in place. A slug that does NOT resolve
1162
+ // to an existing entity → targetEntityId stays empty → this is a CREATE (no
1163
+ // false-positive re-plug). target_entity_id remains the primary/documented
1164
+ // handle. Best-effort resolve (pickup contract); never fetches the public URL.
1165
+ let targetEntityId = target_entity_id;
1166
+ if ((typeof targetEntityId !== "string" || targetEntityId.length === 0) && grid && slug) {
1167
+ const resolved = await resolveEntityViaPickup(ctx, { target: slug, grid });
1168
+ if (resolved?.entity_id) targetEntityId = resolved.entity_id;
1169
+ }
1170
+
1171
+ const isEdit = typeof targetEntityId === "string" && targetEntityId.length > 0;
1172
+
1173
+ // An inspiration edit content-versions the FIRST uploaded artifact — when a
1174
+ // multi-file folder rides a re-plug, put the primary entry first so the edit
1175
+ // swaps the right file (index.html > any .html > everything else).
1176
+ if (isEdit && artifacts.length > 1) {
1177
+ const prio = (a) =>
1178
+ a.path === "index.html" ? 0 : /\.html?$/i.test(a.path) ? 1 : a.path.startsWith(".") ? 3 : 2;
1179
+ artifacts = artifacts
1180
+ .map((a, i) => ({ a, i }))
1181
+ .sort((x, y) => prio(x.a) - prio(y.a) || x.i - y.i)
1182
+ .map((x) => x.a);
1183
+ }
1184
+
1185
+ // ── Auth wire selection ─────────────────────────────────────────────────────
1186
+ const authToken = anon === true ? null : await ctx.getToken();
1187
+ let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
1188
+ if (isEdit && !ownerToken) {
1189
+ // Recover the owner token from session state when re-plugging the drop this
1190
+ // session made anonymously.
1191
+ if (ctx.state.lastDrop?.entity_id === targetEntityId && ctx.state.lastDrop.owner_token) {
1192
+ ownerToken = ctx.state.lastDrop.owner_token;
1193
+ } else if (
1194
+ ctx.state.lastAnonClaim?.entity_id === targetEntityId &&
1195
+ ctx.state.lastAnonClaim.token
1196
+ ) {
1197
+ ownerToken = ctx.state.lastAnonClaim.token;
1198
+ }
1199
+ }
1200
+ // An anon-minted (Guest-Grid) drop is edited via the owner-token wire even
1201
+ // when signed in — the entity is not in the caller's grid, so an authed edit
1202
+ // of it would 404. Otherwise an edit needs the authed wire.
1203
+ const useAnonWire = isEdit ? Boolean(ownerToken) : !authToken;
1204
+ if (isEdit && !ownerToken && !authToken) {
1205
+ throw new Error(
1206
+ "Re-plugging needs authorization: sign in (grid_login) for an entity in your grid, or pass the " +
1207
+ "owner_token that came back when the drop was created anonymously.",
1208
+ );
1209
+ }
1210
+
1211
+ // Auth-aware inline size cap for the single-file `html` path (the old drop
1212
+ // cap). Anonymous inline pages stay capped at 2 MB; signed-in inline pages get
1213
+ // the larger AUTHED cap. `path` (read from disk) and `artifact_files` are
1214
+ // bounded by PLUG_MAX_TOTAL_BYTES instead, so they never set inlineHtmlBytes.
1215
+ if (inlineHtmlBytes != null) {
1216
+ if (useAnonWire) {
1217
+ if (inlineHtmlBytes > ANON_HTML_MAX_BYTES) {
1218
+ throw new Error(
1219
+ `This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
1220
+ "Trim it, or sign in to publish larger.",
1221
+ );
1222
+ }
1223
+ } else if (inlineHtmlBytes > AUTHED_HTML_MAX_BYTES) {
1224
+ throw new Error(
1225
+ `This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
1226
+ `${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
1227
+ ".html file (read from disk — no inline size limit) instead of pasting it inline.",
1228
+ );
1229
+ }
1230
+ }
1231
+
1232
+ const headers = {};
1233
+ // The authed target grid — also reused for the post-create visibility upgrade.
1234
+ let orgSlug = null;
1235
+ if (!useAnonWire && authToken) {
1236
+ headers["Authorization"] = `Bearer ${authToken}`;
1237
+ // On create, `grid` picks where the entity lands. On re-plug the entity's
1238
+ // home grid is authoritative (it never moves) — but the API still resolves
1239
+ // the caller's membership from this header and requires it to MATCH the
1240
+ // entity's grid, so pass `grid` here too when the target lives outside the
1241
+ // active grid.
1242
+ orgSlug = grid || (await ctx.getActiveGrid());
1243
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1244
+ if (orgSlug) {
1245
+ headers["X-CloudGrid-Grid"] = orgSlug;
1246
+ headers["X-CloudGrid-Org"] = orgSlug;
1247
+ }
1248
+ }
1249
+ if (useAnonWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
1250
+ headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
1251
+ headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
1252
+ }
1253
+ if (useAnonWire && ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
1254
+
1255
+ // ── CREATE manifest injection (issue #48) ───────────────────────────────────
1256
+ // On a folder-walk create, a `cloudgrid.yaml` on disk is walked into the tree
1257
+ // and — because directory reads surface it before the nested `services/…`
1258
+ // files — rides the multipart body as the FIRST `artifact` part, with the
1259
+ // walk's uniform `application/octet-stream` content-type. The runtime build
1260
+ // orchestrator relies on the manifest leading the bundle (it drives the
1261
+ // service graph + the entity name). The inline `artifact_files` create used to
1262
+ // APPEND the `cloudgrid_yaml` manifest LAST and as `text/plain`, so a
1263
+ // multi-service runtime rolled out with no service graph (0 replicas /
1264
+ // rollout_failed) and an auto `drop-XXXX` name. Fold the manifest into the
1265
+ // artifact list as the first entry (deduping any `cloudgrid.yaml` the caller
1266
+ // already inlined) so both create paths emit a byte-equivalent bundle.
1267
+ if (!isEdit && cloudgrid_yaml) {
1268
+ const manifest = { path: "cloudgrid.yaml", buffer: Buffer.from(cloudgrid_yaml, "utf8") };
1269
+ const rest = artifacts.filter((a) => a.path !== "cloudgrid.yaml");
1270
+ artifacts = [manifest, ...rest];
1271
+ }
1272
+
1273
+ // ── Wire assembly ───────────────────────────────────────────────────────────
1274
+ const form = new FormData();
1275
+ for (const a of artifacts) {
1276
+ // Folder-walk / artifact_files parts ride as octet-stream (server sniffs by
1277
+ // name); the single-file `html` path carries text/html so a bare inline page
1278
+ // renders instead of downloading (the old drop behavior).
1279
+ form.append("artifact", new Blob([a.buffer], { type: a.type || "application/octet-stream" }), a.path);
1280
+ }
1281
+ if (isEdit) {
1282
+ form.append("target_entity_id", targetEntityId);
1283
+ if (useAnonWire) {
1284
+ form.append("owner_token", ownerToken);
1285
+ } else {
1286
+ // The authed update path requires a `cloudgrid.yaml` part
1287
+ // (materializePlugTarball); an inspiration edit ignores its content.
1288
+ form.append(
1289
+ "cloudgrid.yaml",
1290
+ new Blob([cloudgrid_yaml || ""], { type: "text/plain" }),
1291
+ "cloudgrid.yaml",
1292
+ );
1293
+ }
1294
+ }
1295
+ // Honor the manifest name (issue #48): send `name:` parsed from `cloudgrid_yaml`
1296
+ // as an explicit name/slug hint so the created entity uses it instead of an
1297
+ // auto `drop-XXXX` slug. Harmless if the server owns slug generation; on a
1298
+ // re-plug the entity's name is authoritative, so only send on create.
1299
+ if (!isEdit) {
1300
+ const manifestName = parseManifestName(cloudgrid_yaml);
1301
+ if (manifestName) {
1302
+ form.append("name", manifestName);
1303
+ form.append("slug", manifestName);
1304
+ }
1305
+ }
1306
+ if (hints?.kind) {
1307
+ // `kind_hint` is what the create orchestrator reads; `hints_kind` is the
1308
+ // route's structured field on the update path. Send both — each path
1309
+ // ignores the other's.
1310
+ form.append("kind_hint", hints.kind);
1311
+ form.append("hints_kind", hints.kind);
1312
+ }
1313
+ if (hints?.yaml) form.append("hints_yaml", hints.yaml);
1314
+
1315
+ let res;
1316
+ try {
1317
+ res = await fetchImpl(`${API_BASE}/api/v2/plug`, {
1318
+ method: "POST",
1319
+ headers,
1320
+ body: form,
1321
+ signal: AbortSignal.timeout(uploadTimeoutMs),
1322
+ });
1323
+ } catch (err) {
1324
+ if (err?.name === "AbortError" || err?.name === "TimeoutError") {
1325
+ throw new Error(
1326
+ `The deploy request timed out after ${Math.round(uploadTimeoutMs / 1000)}s. ` +
1327
+ `The build may still be running on CloudGrid — check the deploy status ` +
1328
+ `(poll_url / grid_status, or your grid) before deploying again, so you don't create a duplicate.`,
1329
+ );
1330
+ }
1331
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1332
+ }
1333
+ const raw = await res.text();
1334
+ let data = null;
1335
+ try {
1336
+ data = JSON.parse(raw);
1337
+ } catch {
1338
+ /* handled below */
1339
+ }
1340
+ if (!res.ok) {
1341
+ const code = data?.error?.code;
1342
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
1343
+ const flags = {
1344
+ edition: ctx.edition,
1345
+ isEdit,
1346
+ isAnon: useAnonWire,
1347
+ signedIn: Boolean(authToken),
1348
+ };
1349
+ // Self-heal rung: a signed-in CREATE that hits the known 400 SCOPE_INVALID
1350
+ // platform bug is retried through the bundled CLI — LOCAL edition only,
1351
+ // create only (never edits), never anonymous.
1352
+ if (
1353
+ res.status === 400 &&
1354
+ code === "SCOPE_INVALID" &&
1355
+ ctx.edition === "local" &&
1356
+ !isEdit &&
1357
+ !useAnonWire &&
1358
+ authToken
1359
+ ) {
1360
+ return plugViaCliFallback(ctx, artifacts, deps);
1361
+ }
1362
+ throw new Error(plugErrorMessage(res.status, code, msg, flags));
1363
+ }
1364
+
1365
+ // Anon-session cookie continuity.
1366
+ const setCookies = res.headers.getSetCookie
1367
+ ? res.headers.getSetCookie()
1368
+ : [res.headers.get("set-cookie")].filter(Boolean);
1369
+ const anonCookie = (setCookies || [])
1370
+ .map((c) => (c || "").split(";")[0])
1371
+ .find((c) => c.startsWith("cg_anon_session="));
1372
+ if (anonCookie) ctx.state.anonCookie = anonCookie;
1373
+
1374
+ const url = resolvePlugUrl(data);
1375
+ let freshOwnerToken = typeof data.owner_token === "string" && data.owner_token.length > 0
1376
+ ? data.owner_token
1377
+ : null;
1378
+ if (!freshOwnerToken && data.claim_url) {
1379
+ try {
1380
+ freshOwnerToken = new URL(data.claim_url).searchParams.get("token");
1381
+ } catch {
1382
+ freshOwnerToken = null;
1383
+ }
1384
+ }
1385
+
1386
+ // Session continuity — remember the last plug for re-plug handles.
1387
+ if (data.entity_id || url) {
1388
+ ctx.state.lastDrop = {
1389
+ entity_id: data.entity_id ?? null,
1390
+ url: url ?? null,
1391
+ owner_token: useAnonWire ? (freshOwnerToken ?? ownerToken ?? null) : null,
1392
+ };
1393
+ }
1394
+ if (useAnonWire && (data.claim_url || freshOwnerToken)) {
1395
+ ctx.state.lastAnonClaim = {
1396
+ token: freshOwnerToken,
1397
+ entity_id: data.entity_id ?? null,
1398
+ url,
1399
+ };
1400
+ } else if (!useAnonWire) {
1401
+ ctx.state.lastAnonClaim = null;
1402
+ }
1403
+
1404
+ const structured = {
1405
+ ...(data.entity_id ? { entity_id: data.entity_id } : {}),
1406
+ ...(data.slug ? { slug: data.slug } : {}),
1407
+ grid: data.grid ?? null,
1408
+ ...(url ? { url } : {}),
1409
+ ...(data.poll_url ? { poll_url: data.poll_url } : {}),
1410
+ status: data.status ?? (isEdit ? "updated" : "created"),
1411
+ ...(data.claim_url ? { claim_url: data.claim_url } : {}),
1412
+ ...(data.claim_message ? { claim_message: data.claim_message } : {}),
1413
+ // Spec v2 omits owner_token from the output block — a spec bug (the anon
1414
+ // wire cannot re-plug without it). Included deliberately; flagged upstream.
1415
+ ...(freshOwnerToken ? { owner_token: freshOwnerToken } : {}),
1416
+ };
1417
+
1418
+ // Accurate status (issue #48): a runtime create/edit is an ASYNC build — the
1419
+ // server replies `status: "building"` (+ a poll_url) while the rollout is still
1420
+ // in flight. Do NOT claim "Live"/"Updated in place" for a build that has not
1421
+ // finished; that reported success for apps that then rolled-out-failed. Only
1422
+ // the terminal states get the live wording; anything still building points at
1423
+ // the poll_url and grid_status.
1424
+ const isBuilding = data.status === "building" || Boolean(data.poll_url);
1425
+
1426
+ // An authed CREATE of an INSPIRATION (the single-file `html` path, or a server-
1427
+ // detected inspiration — NOT a runtime app/agent, NOT a build in flight, NOT an
1428
+ // anon guest drop). This is the case that (on the hosted edition) must be made
1429
+ // link-visible so the shared URL renders without a sign-in wall.
1430
+ const detectedKind = data.detection?.kind;
1431
+ const isInspirationCreate =
1432
+ !isEdit &&
1433
+ !useAnonWire &&
1434
+ !isBuilding &&
1435
+ detectedKind !== "app" &&
1436
+ detectedKind !== "agent" &&
1437
+ (inlineHtmlBytes != null || detectedKind === "inspiration");
1438
+
1439
+ const lines = [];
1440
+ if (isBuilding) {
1441
+ lines.push(
1442
+ isEdit
1443
+ ? `Building (async): ${url} — the update is deploying, not live yet.`
1444
+ : `Building (async): ${url} — the deploy is in progress, not live yet.`,
1445
+ );
1446
+ lines.push(
1447
+ data.poll_url
1448
+ ? `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.`
1449
+ : "Run grid_status until it is ready. Do not report it as live until then.",
1450
+ );
1451
+ } else if (isEdit) {
1452
+ lines.push(`Updated in place: ${url}`);
1453
+ } else if (isInspirationCreate) {
1454
+ // Authed inspiration create — owned by the caller. Wording mirrors the drop verb.
1455
+ lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Published to your org: ${url}`);
1456
+ if (ctx.edition !== "web") lines.push("Owned by you.");
1457
+ } else {
1458
+ lines.push(`Live: ${url}`);
1459
+ }
1460
+ if (data.entity_id) {
1461
+ lines.push(
1462
+ `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.`,
1463
+ );
1464
+ }
1465
+ if (data.claim_message) lines.push(data.claim_message);
1466
+ if (isEdit && useAnonWire) {
1467
+ lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
1468
+ }
1469
+
1470
+ // Visibility is the user's choice — never set silently. On a NEW deploy,
1471
+ // surface the current visibility + the full option set and have the agent ASK
1472
+ // the user, then apply their answer via grid_set_sharing. On an edit, leave the
1473
+ // entity's existing visibility untouched (don't re-ask on every re-plug).
1474
+ if (!isEdit && data.entity_id) {
1475
+ const current = typeof data.visibility === "string" ? data.visibility : null;
1476
+ structured.console_url = CONSOLE_URL;
1477
+ if (current) structured.current_visibility = current;
1478
+ structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
1479
+ lines.push(`Manage all your apps in your grid: ${CONSOLE_URL}`);
1480
+ lines.push(
1481
+ `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: ${
1482
+ Object.entries(VISIBILITY_LABELS)
1483
+ .map(([v, l]) => `${v} (${l})`)
1484
+ .join("; ")
1485
+ }.`,
1486
+ );
1487
+ }
1488
+ return { text: lines.join("\n"), structured };
1489
+ }
1490
+
1491
+ // ── grid_copy_app / grid_download_source — direct-API verbs (spec v2 §5–6) ────────
1492
+
1493
+ async function authedApiCall(ctx, { method, pathName, body, verb }) {
1494
+ const token = await ctx.getToken();
1495
+ if (!token) {
1496
+ throw new Error(`${verb} requires sign-in. Run grid_login first.`);
1497
+ }
1498
+ const headers = { Authorization: `Bearer ${token}` };
1499
+ const orgSlug = await ctx.getActiveGrid();
1500
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1501
+ if (orgSlug) {
1502
+ headers["X-CloudGrid-Grid"] = orgSlug;
1503
+ headers["X-CloudGrid-Org"] = orgSlug;
1504
+ }
1505
+ if (body !== undefined) headers["Content-Type"] = "application/json";
1506
+ let res;
1507
+ try {
1508
+ res = await fetch(`${API_BASE}${pathName}`, {
1509
+ method,
1510
+ headers,
1511
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
1512
+ });
1513
+ } catch (err) {
1514
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1515
+ }
1516
+ const raw = await res.text();
1517
+ let data = null;
1518
+ try {
1519
+ data = JSON.parse(raw);
1520
+ } catch {
1521
+ /* handled below */
1522
+ }
1523
+ if (!res.ok) {
1524
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
1525
+ const codeStr = data?.error?.code || null;
1526
+ const err = new Error(`${verb} failed (HTTP ${res.status}${codeStr ? ` ${codeStr}` : ""}): ${msg}`);
1527
+ // Expose the structured status/code so callers can branch (e.g. runFork's
1528
+ // NOT_A_RUNTIME → inspiration-route fallback). Additive: existing callers
1529
+ // only read `.message`.
1530
+ err.status = res.status;
1531
+ err.code = codeStr;
1532
+ throw err;
1533
+ }
1534
+ return data;
1535
+ }
1536
+
1537
+ // Fork: start a NEW entity from an existing source, copy-on-write with lineage.
1538
+ // Kind-aware (one fork verb, mirrors the CLI `fork` command): the caller may
1539
+ // not know the source's kind, so try the runtime route first and, when the
1540
+ // server reports the target is an inspiration (`400 NOT_A_RUNTIME`, emitted by
1541
+ // requireEntityAccess before the runtime handler runs), retry the inspiration
1542
+ // route. The inspiration remix lands in the caller's active grid, so no
1543
+ // `into_org_slug`; `source_version_id` has no meaning for inspirations.
1544
+ export async function runFork(ctx, { id, into_org_slug, name, source_version_id }) {
1545
+ let data;
1546
+ try {
1547
+ data = await authedApiCall(ctx, {
1548
+ method: "POST",
1549
+ pathName: `/api/v2/runtimes/${encodeURIComponent(id)}/fork`,
1550
+ body: {
1551
+ ...(into_org_slug ? { into_org_slug } : {}),
1552
+ ...(name ? { name } : {}),
1553
+ ...(source_version_id ? { source_version_id } : {}),
1554
+ },
1555
+ verb: "Fork",
1556
+ });
1557
+ } catch (err) {
1558
+ if (err && err.code === "NOT_A_RUNTIME") {
1559
+ data = await authedApiCall(ctx, {
1560
+ method: "POST",
1561
+ pathName: `/api/v2/inspirations/${encodeURIComponent(id)}/fork`,
1562
+ body: { ...(name ? { name } : {}) },
1563
+ verb: "Fork",
1564
+ });
1565
+ } else {
1566
+ throw err;
1567
+ }
1568
+ }
1569
+ const gridSlug = data?.grid_slug ?? data?.org?.slug ?? null;
1570
+ const lines = [
1571
+ `Forked: ${data?.name ?? id} (entity_id=${data?.entity_id ?? "?"})${gridSlug ? ` in grid ${gridSlug}` : ""}`,
1572
+ `Lineage: forked_from=${data?.forked_from ?? "?"}${data?.forked_from_version_id ? ` @ ${data.forked_from_version_id}` : ""}`,
1573
+ ];
1574
+ return {
1575
+ text: lines.join("\n"),
1576
+ structured: {
1577
+ entity_id: data?.entity_id ?? null,
1578
+ name: data?.name ?? null,
1579
+ kind: data?.kind ?? null,
1580
+ grid_slug: gridSlug,
1581
+ forked_from: data?.forked_from ?? null,
1582
+ forked_from_version_id: data?.forked_from_version_id ?? null,
1583
+ current_version_id: data?.current_version_id ?? null,
1584
+ },
1585
+ };
1586
+ }
1587
+
1588
+ // Download: signed, time-limited (15-minute) source-bundle URLs. No entity is
1589
+ // created and no registry state changes.
1590
+ export async function runDownload(ctx, { id, version }) {
1591
+ const qs = version ? `?version=${encodeURIComponent(version)}` : "";
1592
+ const data = await authedApiCall(ctx, {
1593
+ method: "GET",
1594
+ pathName: `/api/v2/runtimes/${encodeURIComponent(id)}/source${qs}`,
1595
+ verb: "Download",
1596
+ });
1597
+ const services = data?.services && typeof data.services === "object" ? data.services : {};
1598
+ const lines = [`Source bundle URLs for ${data?.name ?? id} (valid ~15 minutes):`];
1599
+ for (const [svc, u] of Object.entries(services)) lines.push(` ${svc}: ${u}`);
1600
+ if (Object.keys(services).length === 0) lines.push(" (no services returned)");
1601
+ return {
1602
+ text: lines.join("\n"),
1603
+ structured: {
1604
+ entity_id: data?.entity_id ?? null,
1605
+ name: data?.name ?? null,
1606
+ services,
1607
+ domain: data?.domain ?? null,
1608
+ },
1609
+ };
1610
+ }
1611
+
1612
+ // Change an inspiration's visibility. Authed, direct API — works on the hosted
1613
+ // edition where the CLI-wrapping share tool is unavailable. Defaults to the drop
1614
+ // made in this session, so "make it private" needs no ids.
1615
+ export async function runVisibility(ctx, { target, visibility, org }) {
1616
+ const token = await ctx.getToken();
1617
+ if (!token) {
1618
+ throw new Error("Changing visibility needs an owner. Run grid_login first.");
1619
+ }
1620
+ const id = target || ctx.state.lastDrop?.entity_id;
1621
+ if (!id) {
1622
+ throw new Error("No target. Pass the entity id, or drop something first in this session.");
1623
+ }
1624
+ const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
1625
+ const orgSlug = org || (await ctx.getActiveGrid());
1626
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1627
+ if (orgSlug) {
1628
+ headers["X-CloudGrid-Grid"] = orgSlug;
1629
+ headers["X-CloudGrid-Org"] = orgSlug;
1630
+ }
1631
+ let res;
1632
+ try {
1633
+ res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(id)}`, {
1634
+ method: "PATCH",
1635
+ headers,
1636
+ body: JSON.stringify({ visibility }),
1637
+ });
1638
+ } catch (err) {
1639
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1640
+ }
1641
+ const raw = await res.text();
1642
+ let data = null;
1643
+ try { data = JSON.parse(raw); } catch { /* handled below */ }
1644
+ if (!res.ok) {
1645
+ const msg = data?.error?.message || raw || `HTTP ${res.status}`;
1646
+ const hint = data?.error?.details?.[0]?.hint;
1647
+ throw new Error(`Visibility change failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`);
1648
+ }
1649
+ const lines = [`Visibility is now ${visibility}.`];
1650
+ if (data?.url) lines.push(data.url);
1651
+ return {
1652
+ text: lines.join("\n"),
1653
+ structured: {
1654
+ visibility,
1655
+ ...(data?.url ? { url: data.url } : {}),
1656
+ },
1657
+ };
1658
+ }
1659
+
1660
+ // Max HTML we'll return inline from runSource. Past this, we return the first
1661
+ // slice with truncated:true — a re-plug needs the complete document, so a
1662
+ // truncated body is a signal the drop is likely multi-file rather than a single
1663
+ // editable HTML document.
1664
+ const SOURCE_MAX_BYTES = 1_500_000;
1665
+
1666
+ // Reject anything whose host is not `*.cloudgrid.io` (apex `cloudgrid.io`
1667
+ // included). SSRF guard for runSource: we only ever fetch live CloudGrid drops
1668
+ // server-side, never arbitrary hosts. Returns true when the host is allowed.
1669
+ function isCloudgridHost(hostname) {
1670
+ const h = String(hostname || "").toLowerCase();
1671
+ return h === "cloudgrid.io" || h.endsWith(".cloudgrid.io");
1672
+ }
1673
+
1674
+ // Shape an HTML string into the grid_get_app_source result (shared by the API-read
1675
+ // and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES. `extra` carries
1676
+ // optional edition metadata resolved from the pickup contract (kind, single_html,
1677
+ // capabilities, replug_handle, source_download_url) — merged into structured.
1678
+ function shapeSourceResult(sourceUrl, entityId, htmlStr, extra = {}) {
1679
+ const buf = Buffer.from(htmlStr, "utf-8");
1680
+ const totalBytes = buf.length;
1681
+ const truncated = totalBytes > SOURCE_MAX_BYTES;
1682
+ const html = truncated ? buf.subarray(0, SOURCE_MAX_BYTES).toString("utf-8") : htmlStr;
1683
+ const lines = [
1684
+ `Current source for ${sourceUrl} (${totalBytes} bytes) — edit this and re-plug with target_entity_id to update the same URL:`,
1685
+ ];
1686
+ if (truncated) {
1687
+ lines.push(
1688
+ "(too large to return in full; re-plug needs the complete document — consider that this drop may be multi-file)",
1689
+ );
1690
+ }
1691
+ lines.push("", html);
1692
+ return {
1693
+ text: lines.join("\n"),
1694
+ structured: { url: sourceUrl, entity_id: entityId ?? null, bytes: totalBytes, truncated, html, ...extra },
1695
+ };
1696
+ }
1697
+
1698
+ // Read an inspiration's HTML via the API (server-side storage read) instead of
1699
+ // fetching the public *.cloudgrid.io URL. Critical on the hosted edition: the
1700
+ // MCP pod can reach the API (it POSTs /api/v2/plug) but CANNOT egress to the
1701
+ // public ingress ("fetch failed"). getInspirationSource resolves directly by
1702
+ // entity_id when the path segment is a UUID (no org context needed), else by
1703
+ // slug + the X-CloudGrid-Grid active-org header. Returns the html string, or
1704
+ // null if the API can't serve it (→ caller falls back to the direct fetch).
1705
+ async function readInspirationSourceViaApi(ctx, { entityId, slug, grid }) {
1706
+ let token = null;
1707
+ try { token = await ctx.getToken(); } catch { /* anonymous is fine for public */ }
1708
+ const baseHeaders = token ? { Authorization: `Bearer ${token}` } : {};
1709
+ const attempts = [];
1710
+ if (entityId) attempts.push({ seg: entityId, grid: null }); // UUID → resolves by id, no org ctx
1711
+ if (slug) attempts.push({ seg: slug, grid: grid || null }); // slug → needs org (grid) context
1712
+ for (const a of attempts) {
1713
+ try {
1714
+ const headers = { ...baseHeaders };
1715
+ if (a.grid) headers["X-CloudGrid-Grid"] = a.grid;
1716
+ const res = await fetch(
1717
+ `${API_BASE}/api/v2/inspirations/${encodeURIComponent(a.seg)}/source`,
1718
+ { method: "GET", headers, signal: AbortSignal.timeout(15_000) },
1719
+ );
1720
+ if (!res.ok) continue;
1721
+ // The route (GET /v2/inspirations/:seg/source) serves the RAW HTML bytes
1722
+ // as text/html — NOT a JSON { html } envelope. Read the body as text.
1723
+ // (Tolerate a JSON { html } shape too, in case a variant ever returns it.)
1724
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
1725
+ if (ct.includes("application/json")) {
1726
+ const data = await res.json().catch(() => null);
1727
+ if (data && typeof data.html === "string") return data.html;
1728
+ continue;
1729
+ }
1730
+ const html = await res.text().catch(() => null);
1731
+ if (typeof html === "string" && html.length > 0) return html;
1732
+ } catch {
1733
+ // try the next attempt, then the public-fetch fallback
1734
+ }
1735
+ }
1736
+ return null;
1737
+ }
1738
+
1739
+ // Resolve a public URL / grid+slug to a REAL entity_id (+ edition metadata) via
1740
+ // the deployed pickup contract (POST /api/v2/entities/:target/pickup — the same
1741
+ // endpoint runClaim uses). Used by runSource when a bare URL arrives with no
1742
+ // session entity_id (a fresh chat): the contract returns
1743
+ // { entity_id, slug, grid, kind, single_html, capabilities, replug_handle,
1744
+ // source_download_url, ... }
1745
+ // so the agent can re-plug the SAME entity in place. This is a metadata resolve
1746
+ // (no claim_token in the body → no ownership transfer). Best-effort: returns null
1747
+ // on any failure so the caller falls back to today's behavior — and NEVER fetches
1748
+ // the public *.cloudgrid.io URL (the hosted pod cannot egress to it).
1749
+ async function resolveEntityViaPickup(ctx, { target, url, grid }) {
1750
+ const pathSeg = target || url;
1751
+ if (!pathSeg) return null;
1752
+ let token = null;
1753
+ try { token = await ctx.getToken(); } catch { /* anonymous is fine */ }
1754
+ const headers = { "Content-Type": "application/json" };
1755
+ if (token) headers["Authorization"] = `Bearer ${token}`;
1756
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1757
+ if (grid) {
1758
+ headers["X-CloudGrid-Grid"] = grid;
1759
+ headers["X-CloudGrid-Org"] = grid;
1760
+ }
1761
+ if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
1762
+ try {
1763
+ const res = await fetch(
1764
+ `${API_BASE}/api/v2/entities/${encodeURIComponent(pathSeg)}/pickup`,
1765
+ {
1766
+ method: "POST",
1767
+ headers,
1768
+ // Send the URL as a resolution key too (the contract accepts id, slug, or
1769
+ // a {url} body). NO claim_token → resolve only, never a claim.
1770
+ body: JSON.stringify(url ? { url } : {}),
1771
+ signal: AbortSignal.timeout(15_000),
1772
+ },
1773
+ );
1774
+ if (!res.ok) return null;
1775
+ const data = await res.json().catch(() => null);
1776
+ if (data && typeof data.entity_id === "string" && data.entity_id.length > 0) return data;
1777
+ return null;
1778
+ } catch {
1779
+ return null;
1780
+ }
1781
+ }
1782
+
1783
+ // Fetch a drop's/inspiration's current deployed HTML inline as text so an agent
1784
+ // that lost the content can edit it and re-plug in place. Resolves the fetch URL
1785
+ // (explicit url → session lastDrop → composed grid+slug), then reads the HTML
1786
+ // via the API (reachable) BEFORE falling back to a direct `*.cloudgrid.io` fetch
1787
+ // (SSRF-guarded, capped at SOURCE_MAX_BYTES). Uses the global `fetch` seam so
1788
+ // tests can mock it.
1789
+ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
1790
+ const last = ctx.state.lastDrop;
1791
+ // Resolution order: explicit url → session lastDrop.url (if entity_id matches
1792
+ // or no entity_id was given) → composePlugUrl(grid, slug) → fail.
1793
+ let target = null;
1794
+ if (typeof url === "string" && url.length > 0) {
1795
+ target = url;
1796
+ } else if (last?.url && (!entity_id || last.entity_id === entity_id)) {
1797
+ target = last.url;
1798
+ } else if (grid && slug) {
1799
+ // Inspirations/HTML drops are path-based at the org apex.
1800
+ target = composePlugUrl({ slug, grid });
1801
+ }
1802
+ if (!target) {
1803
+ throw new Error(
1804
+ "I don't have this drop's URL — pass the url (e.g. https://<grid>.cloudgrid.io/<slug>) or grid+slug.",
1805
+ );
1806
+ }
1807
+
1808
+ let parsed;
1809
+ try {
1810
+ parsed = new URL(target);
1811
+ } catch {
1812
+ throw new Error(`Not a valid URL: ${target}`);
1813
+ }
1814
+ if (parsed.protocol !== "https:" || !isCloudgridHost(parsed.hostname)) {
1815
+ throw new Error(
1816
+ `Refusing to fetch ${target}: source retrieval is limited to https://*.cloudgrid.io drops.`,
1817
+ );
1818
+ }
1819
+
1820
+ const resolvedUrl = parsed.toString();
1821
+ let eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
1822
+
1823
+ // Derive the grid/slug hint from the inspiration path URL (`grid.cloudgrid.io/slug`).
1824
+ const host = parsed.hostname;
1825
+ const gridHint = grid ?? (host.endsWith(".cloudgrid.io") && !host.includes("--") && host.split(".").length === 3
1826
+ ? host.split(".")[0] : null);
1827
+ const slugHint = slug ?? (parsed.pathname.replace(/^\/+/, "").split("/")[0] || null);
1828
+
1829
+ // ── URL → entity_id (fresh chat, no session) via the pickup contract ──────
1830
+ // When a bare URL/slug arrived with no known entity_id, resolve a REAL
1831
+ // entity_id (+ edition metadata) so the agent can re-plug in place. Best-
1832
+ // effort: a failure falls back to today's behavior (never regress the read),
1833
+ // and we NEVER fetch the public URL for resolution.
1834
+ let extra = {};
1835
+ if (!eid) {
1836
+ const pickup = await resolveEntityViaPickup(ctx, { target: slugHint, url: resolvedUrl, grid: gridHint });
1837
+ if (pickup?.entity_id) {
1838
+ eid = pickup.entity_id;
1839
+ extra = {
1840
+ ...(pickup.kind ? { kind: pickup.kind } : {}),
1841
+ ...(typeof pickup.single_html === "boolean" ? { single_html: pickup.single_html } : {}),
1842
+ ...(pickup.capabilities ? { capabilities: pickup.capabilities } : {}),
1843
+ ...(pickup.replug_handle ? { replug_handle: pickup.replug_handle } : {}),
1844
+ ...(pickup.source_download_url ? { source_download_url: pickup.source_download_url } : {}),
1845
+ };
1846
+ }
1847
+ }
1848
+
1849
+ // ── API-first (reachable) ────────────────────────────────────────────────
1850
+ // Read the HTML from the API server-side rather than fetching the public URL.
1851
+ // The hosted MCP pod can reach the API but NOT the public *.cloudgrid.io
1852
+ // ingress ("fetch failed") — this is the fix for hosted edit-in-place.
1853
+ const apiHtml = await readInspirationSourceViaApi(ctx, { entityId: eid, slug: slugHint, grid: gridHint });
1854
+ if (apiHtml != null) return shapeSourceResult(resolvedUrl, eid, apiHtml, extra);
1855
+
1856
+ // ── Fallback: direct public fetch ────────────────────────────────────────
1857
+ // Local edition has normal egress; last resort on hosted (e.g. a runtime app
1858
+ // whose source the inspiration route can't serve).
1859
+ let res;
1860
+ try {
1861
+ res = await fetch(resolvedUrl, {
1862
+ method: "GET",
1863
+ redirect: "follow",
1864
+ signal: AbortSignal.timeout(15_000),
1865
+ });
1866
+ } catch (err) {
1867
+ throw new Error(`Could not reach the live drop at ${resolvedUrl}: ${err.message}`);
1868
+ }
1869
+
1870
+ // A redirect must not escape the allow-list (fetch follows automatically; the
1871
+ // final response URL is the one we actually read).
1872
+ if (res.url && res.url !== resolvedUrl) {
1873
+ let finalHost;
1874
+ try { finalHost = new URL(res.url).hostname; } catch { finalHost = ""; }
1875
+ if (!isCloudgridHost(finalHost)) {
1876
+ throw new Error(
1877
+ `Refusing to follow a redirect off CloudGrid (${res.url}): source retrieval is limited to https://*.cloudgrid.io.`,
1878
+ );
1879
+ }
1880
+ }
1881
+
1882
+ if (!res.ok) {
1883
+ // Graceful fail — never throw a raw fetch error at the model.
1884
+ throw new Error(
1885
+ `Couldn't read the live drop (HTTP ${res.status}). It may be expired, private, or claimed.`,
1886
+ );
1887
+ }
1888
+
1889
+ const buf = Buffer.from(await res.arrayBuffer());
1890
+ return shapeSourceResult(resolvedUrl, eid, buf.toString("utf-8"), extra);
1891
+ }