@cloudgrid-io/mcp 0.20.7 → 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.
- package/README.md +40 -31
- package/package.json +2 -1
- package/src/corpus/examples/docs-app/index.md +113 -0
- package/src/playbook.js +135 -0
- package/src/tools/cli.js +446 -0
- package/src/tools/constants.js +106 -0
- package/src/tools/deploy.js +1677 -0
- package/src/tools/register.js +1165 -0
- package/src/tools/util.js +17 -0
- 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
|
+
}
|