@cloudgrid-io/mcp 0.17.0 → 0.19.0
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/package.json +1 -1
- package/src/corpus/capability-map.md +12 -6
- package/src/corpus/cloudgrid-yaml.md +7 -5
- package/src/corpus/cookbook.md +4 -4
- package/src/corpus/install-for-agents.md +1 -2
- package/src/corpus/skill-plug.md +69 -6
- package/src/corpus/skills-readme.md +9 -9
- package/src/corpus/templates/api-docs/README.md +1 -1
- package/src/corpus/templates/api-docs/index.md +2 -3
- package/src/corpus/templates/appointment-booking/AGENTS.md +1 -1
- package/src/corpus/templates/changelog/README.md +1 -1
- package/src/corpus/templates/changelog/index.md +2 -3
- package/src/corpus/templates/company-website/README.md +2 -3
- package/src/corpus/templates/company-website/cloudgrid.yaml +1 -1
- package/src/corpus/templates/company-website/index.md +2 -3
- package/src/corpus/templates/dashboard/cloudgrid.yaml +1 -1
- package/src/corpus/templates/dashboard/index.md +2 -3
- package/src/corpus/templates/deck/cloudgrid.yaml +1 -1
- package/src/corpus/templates/deck/index.md +2 -3
- package/src/corpus/templates/docs-site/README.md +1 -1
- package/src/corpus/templates/docs-site/index.md +2 -3
- package/src/corpus/templates/landing-page/cloudgrid.yaml +1 -1
- package/src/corpus/templates/landing-page/index.md +2 -3
- package/src/corpus/templates/membership-site/AGENTS.md +1 -1
- package/src/corpus/templates/portfolio/README.md +1 -1
- package/src/corpus/templates/portfolio/index.md +2 -3
- package/src/corpus/templates/product-launch/README.md +2 -3
- package/src/corpus/templates/product-launch/cloudgrid.yaml +1 -1
- package/src/corpus/templates/product-launch/index.md +2 -3
- package/src/corpus/templates/report/cloudgrid.yaml +1 -1
- package/src/corpus/templates/report/index.md +2 -3
- package/src/corpus/templates/saas-marketing/README.md +1 -1
- package/src/corpus/templates/saas-marketing/index.md +2 -3
- package/src/corpus/templates/status-page/README.md +1 -1
- package/src/corpus/templates/status-page/index.md +2 -3
- package/src/corpus/templates/subscription-management/AGENTS.md +1 -1
- package/src/corpus/templates/waitlist/README.md +1 -1
- package/src/corpus/templates/waitlist/index.md +2 -3
- package/src/corpus/templates/web-app/cloudgrid.yaml +1 -1
- package/src/corpus/templates/web-app/index.md +2 -3
- package/src/corpus/usage.md +2 -2
- package/src/corpus/workflows/api-docs.md +1 -1
- package/src/corpus/workflows/changelog.md +1 -1
- package/src/corpus/workflows/company-website.md +1 -1
- package/src/corpus/workflows/dashboard.md +1 -1
- package/src/corpus/workflows/docs-site.md +1 -1
- package/src/corpus/workflows/landing-page.md +1 -1
- package/src/corpus/workflows/portfolio.md +1 -1
- package/src/corpus/workflows/presentation.md +1 -1
- package/src/corpus/workflows/product-launch.md +1 -1
- package/src/corpus/workflows/report.md +1 -1
- package/src/corpus/workflows/saas-marketing.md +1 -1
- package/src/corpus/workflows/status-page.md +1 -1
- package/src/corpus/workflows/waitlist.md +1 -1
- package/src/corpus/workflows/web-app.md +1 -1
- package/src/tools.js +353 -650
- package/src/web.js +1 -1
- package/src/widgets/org-picker.html +3 -3
- package/src/corpus/skill-drop.md +0 -140
package/src/tools.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Two editions register from here:
|
|
4
4
|
// - local (stdio): full toolset, including the CLI-wrapping tools. Identity
|
|
5
5
|
// comes from ~/.cloudgrid/credentials.
|
|
6
|
-
// - web (HTTP, hosted): the light, CLI-free toolset (
|
|
6
|
+
// - web (HTTP, hosted): the light, CLI-free toolset (plug, claim, login).
|
|
7
7
|
// Identity is a per-session token held in memory.
|
|
8
8
|
//
|
|
9
9
|
// The difference is injected as a `ctx` object, so the tool logic is written once.
|
|
@@ -44,6 +44,14 @@ const ANON_HTML_MAX_BYTES = 2_000_000;
|
|
|
44
44
|
const AUTHED_HTML_MAX_BYTES = 25_000_000;
|
|
45
45
|
const CONSOLE_URL = "https://console.cloudgrid.io/";
|
|
46
46
|
|
|
47
|
+
const VISIBILITY_LABELS = {
|
|
48
|
+
private: "Only you",
|
|
49
|
+
org: "Your org",
|
|
50
|
+
authenticated: "Anyone signed in",
|
|
51
|
+
space: "A space",
|
|
52
|
+
link: "Anyone with the link",
|
|
53
|
+
};
|
|
54
|
+
|
|
47
55
|
// ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
|
|
48
56
|
// The Apps-SDK UI widgets (openai/outputTemplate → a ui:// html resource) render
|
|
49
57
|
// as a broken black frame in ChatGPT today, hiding the plain-text result. Gate
|
|
@@ -64,14 +72,6 @@ const WIDGET_CSP = {
|
|
|
64
72
|
resourceDomains: ["https://*.cloudgrid.io"],
|
|
65
73
|
};
|
|
66
74
|
|
|
67
|
-
const VISIBILITY_LABELS = {
|
|
68
|
-
private: "Only you",
|
|
69
|
-
org: "Your org",
|
|
70
|
-
authenticated: "Anyone signed in",
|
|
71
|
-
space: "A space",
|
|
72
|
-
link: "Anyone with the link",
|
|
73
|
-
};
|
|
74
|
-
|
|
75
75
|
function ok(text) {
|
|
76
76
|
return { content: [{ type: "text", text }] };
|
|
77
77
|
}
|
|
@@ -94,14 +94,18 @@ Operating rules:
|
|
|
94
94
|
6. If a signed-in publish fails with a server error, do not fall back to anonymous publishing (it burns the anonymous quota and downgrades ownership); surface the error, use the CLI fallback if offered, or ask the user.
|
|
95
95
|
7. When signed in and the user has more than one grid, do not assume a target — the publish tools will ask; relay the choice to the user and pass the chosen grid.
|
|
96
96
|
8. When a build/deploy fails unexpectedly, offer to report it to the CloudGrid team — only with the user's explicit consent (ask first). Send just the error + the failed request by default (call grid_report), and never send the whole conversation unless the user agrees (include_conversation). Respect privacy.
|
|
97
|
-
9. To modify an existing
|
|
98
|
-
10. Publishing a heavy or
|
|
97
|
+
9. To modify an existing page when you don't already have its HTML in context, first call grid_source to fetch the current HTML, apply your change, then call grid_plug with target_entity_id (the entity_id) to update the SAME URL in place. Do not ask the user to paste the HTML back.
|
|
98
|
+
10. Publishing a single HTML page: pass it inline as grid_plug's html parameter (a full self-contained document). For a heavy or image-heavy page in the local edition, pass the path parameter instead so it is read from disk (no inline size limit); never base64-encode HTML and never pass a file path (or an @-prefixed path) as html. If a page looks empty, use grid_source to check what was actually published, then re-plug with the real HTML/path and target_entity_id.
|
|
99
99
|
11. Persistence check: if the user needs to SAVE data, share state across users/sessions, log in, or store submissions, that's a runtime app-with-data (Mongo-backed), NOT a static page — static templates keep state only in memory and lose it on refresh. Use the app-with-data workflow. This requires the LOCAL edition (Claude Desktop/Code or the CLI); on the hosted edition, tell the user persistence isn't available there and offer a static version.
|
|
100
|
-
12. To choose what to build: match the request against the workflow when: triggers and the capability-map (grid_fetch({kind:"doc", name:"capability-map"})). Pick the template whose needs: matches what the app requires (persistence → database; scheduled → cron; etc.).
|
|
100
|
+
12. To choose what to build: match the request against the workflow when: triggers and the capability-map (grid_fetch({kind:"doc", name:"capability-map"})). Pick the template whose needs: matches what the app requires (persistence → database; scheduled → cron; etc.). Classify the ARTIFACT to pick the deploy: ONE self-contained HTML page (a single file — CSS+JS inline, images/fonts as data: URIs; that is the normal hosted output) → an inspiration — instant, ANY edition, deploy via grid_plug with the inline html param. Only genuinely SEPARATE files/folders (a real assets/ dir, separate .css/.js files, multiple pages, a SPA build) — OR anything needing needs: (data/server/LLM/cron) → a runtime app — grid_plug on a linked folder with a cloudgrid.yaml, local edition only, async build.
|
|
101
101
|
13. Before writing a cloudgrid.yaml, fetch the reference: grid_fetch({kind:"doc", name:"cloudgrid-yaml"}) — it has the full schema and the needs: vocabulary. Declare infrastructure with needs: (the deployer injects from it): needs: { database: true } → Mongo (DATABASE_MONGODB_URL); needs: { cache: true } → Redis; scheduled work → a service of type: cron (Python or Node). Use needs: OR requires:, never both — declaring both is rejected.
|
|
102
102
|
14. Databases — CloudGrid supports both, so never tell the user to self-host. Managed: needs: { database: true } provisions Mongo and injects DATABASE_MONGODB_URL. Bring-your-own (they already run Postgres / MySQL / MongoDB / Supabase / Neon / PlanetScale / etc.): needs: { database: { tier: external, secret: MY_DB } } plus grid secrets set MY_DB=<connection-string> — the connection string lives in env SECRETS, never committed. If asked "what databases does CloudGrid support?": all of them — the managed CloudGrid database out of the box, or bring your own via keys — ask which they want.
|
|
103
|
+
15. Editing an existing thing from just its URL (a fresh chat, no prior context — e.g. "change the background to green here <url>"): call grid_source(url) first. It resolves the entity_id and returns the current HTML plus its kind, single_html, capabilities, and replug_handle — read those to pick the branch:
|
|
104
|
+
- Single-HTML inspiration you can re-plug (single_html: true and capabilities.replug: true): edit the returned HTML and call grid_plug with target_entity_id (or grid+slug — the replug_handle) to update the SAME URL in place. This works on every edition, including hosted.
|
|
105
|
+
- Multi-file app or agent (kind is app or agent, or single_html: false): do NOT try to edit it as one inline HTML file. Tell the user it is a multi-file <kind>, give them the entity_id and the source (source_download_url), and explain that rebuilding it needs the local edition (Claude Desktop/Code) or the CLI — the hosted server cannot rebuild a multi-file app.
|
|
106
|
+
- Not yours (capabilities.replug: false, reason not_owner): do NOT attempt a re-plug. Offer to fork it into the user's own grid with grid_fork and edit the copy instead.
|
|
103
107
|
|
|
104
|
-
Deploy is edition
|
|
108
|
+
Deploy is via grid_plug on every edition: for a single HTML page pass it inline as the html param (works on the hosted MCP too); for a multi-file app write the files and pass a folder path (local MCP / CLI). A single HTML page deploys synchronously as an inspiration, so you get a URL right away.`;
|
|
105
109
|
|
|
106
110
|
// The corpus subdirectories that grid_fetch serves, keyed by `kind`. Each
|
|
107
111
|
// lives in its own subtree of src/corpus/ (populated by scripts/snapshot-corpus.mjs
|
|
@@ -710,9 +714,9 @@ async function fetchUserOrgs(token) {
|
|
|
710
714
|
}
|
|
711
715
|
}
|
|
712
716
|
|
|
713
|
-
// ── Shared grid disambiguation (
|
|
714
|
-
// The stateless "which grid?" ask
|
|
715
|
-
//
|
|
717
|
+
// ── Shared grid disambiguation (grid_plug) ──────────────────
|
|
718
|
+
// The stateless "which grid?" ask on an authed create. Given the caller's token
|
|
719
|
+
// and a supplied grid, it decides:
|
|
716
720
|
// - supplied grid matches a membership → { proceed: true, grid }
|
|
717
721
|
// - >1 grid and none supplied → { picker } (a ready-to-return result)
|
|
718
722
|
// - exactly one grid → { single: annotatedOrg } — the caller
|
|
@@ -771,31 +775,6 @@ export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, de
|
|
|
771
775
|
return { proceed: true };
|
|
772
776
|
}
|
|
773
777
|
|
|
774
|
-
// After an authenticated web drop, upgrade visibility to "link" so the artifact
|
|
775
|
-
// is shareable and its preview renders without a sign-in wall. Best-effort — a
|
|
776
|
-
// failure here does not fail the drop; the user can always call grid_visibility.
|
|
777
|
-
async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
|
|
778
|
-
const token = await ctx.getToken();
|
|
779
|
-
if (!token || !entityId) return false;
|
|
780
|
-
try {
|
|
781
|
-
const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
782
|
-
// Send the grid-native header; keep X-CloudGrid-Org in parallel (same value)
|
|
783
|
-
// during the org→grid soak. Never send conflicting values → 400 GRID_HEADER_CONFLICT.
|
|
784
|
-
if (orgSlug) {
|
|
785
|
-
hdrs["X-CloudGrid-Grid"] = orgSlug;
|
|
786
|
-
hdrs["X-CloudGrid-Org"] = orgSlug;
|
|
787
|
-
}
|
|
788
|
-
const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
|
|
789
|
-
method: "PATCH",
|
|
790
|
-
headers: hdrs,
|
|
791
|
-
body: JSON.stringify({ visibility: "link" }),
|
|
792
|
-
});
|
|
793
|
-
return res.ok;
|
|
794
|
-
} catch {
|
|
795
|
-
return false;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
778
|
// ── Direct-API tools (both editions) ───────────────────────────────────────────
|
|
800
779
|
function looksLikeFullHtml(s) {
|
|
801
780
|
const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
|
|
@@ -846,6 +825,55 @@ function looksLikePath(s) {
|
|
|
846
825
|
return /^(~|\.{0,2}\/|[A-Za-z]:[\\/]|\/)/.test(t) || /\.[A-Za-z0-9]{1,8}$/.test(t);
|
|
847
826
|
}
|
|
848
827
|
|
|
828
|
+
// Normalize an inline `html` string — grid_plug's ergonomic single-file publish
|
|
829
|
+
// path — into ONE index.html artifact, reusing the same hardening the old drop
|
|
830
|
+
// verb used (decodeIfBase64Html, the @-path/file-path rejection, the base64
|
|
831
|
+
// guard, and the small-fragment wrap). Returns { path, buffer, type }. Throws on
|
|
832
|
+
// a file-path-looking or non-HTML input so a path/base64 blob is never published
|
|
833
|
+
// as page content. The auth-aware inline size cap is enforced later in runPlug
|
|
834
|
+
// (it depends on the anon-vs-authed wire).
|
|
835
|
+
function htmlToArtifact(html, filename) {
|
|
836
|
+
if (typeof html !== "string" || html.length === 0) {
|
|
837
|
+
throw new Error("`html` must be the complete HTML document as a string.");
|
|
838
|
+
}
|
|
839
|
+
// Strip an invented `@`-prefix shorthand, then reject a bare file path — the
|
|
840
|
+
// inline html path takes HTML CONTENT, not a path (use `path` for a file).
|
|
841
|
+
let candidate = html.startsWith("@") ? html.slice(1) : html;
|
|
842
|
+
if (!looksLikeFullHtml(candidate) && looksLikePath(candidate)) {
|
|
843
|
+
throw new Error(
|
|
844
|
+
`This looks like a file path (\`${candidate.trim()}\`), not HTML. Pass the raw HTML inline as ` +
|
|
845
|
+
"`html`, or pass the file/folder via `path` — do not pass a path as `html`.",
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
// Rescue a base64-of-HTML blob passed as `html` (real user repro): decode it
|
|
849
|
+
// rather than publishing a wall of base64 text.
|
|
850
|
+
const { html: resolved } = decodeIfBase64Html(candidate);
|
|
851
|
+
let content = resolved;
|
|
852
|
+
if (!looksLikeFullHtml(content)) {
|
|
853
|
+
const stripped = content.replace(/\s+/g, "");
|
|
854
|
+
const looksBase64 =
|
|
855
|
+
stripped.length >= 64 && stripped.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(stripped);
|
|
856
|
+
const isShortFragment = Buffer.byteLength(content, "utf8") <= 8192;
|
|
857
|
+
const hasTag = /<[a-z][\s\S]*>/i.test(content);
|
|
858
|
+
if (isShortFragment && (hasTag || (!looksBase64 && !looksLikePath(content)))) {
|
|
859
|
+
// Legit "share this snippet" — wrap a small text/markup fragment into a
|
|
860
|
+
// full document (preserve the old drop's friendly behavior).
|
|
861
|
+
content =
|
|
862
|
+
`<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
|
|
863
|
+
`<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
|
|
864
|
+
} else {
|
|
865
|
+
// Large, or base64 that failed to decode to HTML, or a bare file path:
|
|
866
|
+
// refuse instead of silently publishing garbage.
|
|
867
|
+
const kind = looksBase64 ? "base64" : looksLikePath(content) ? "a file path" : "raw data";
|
|
868
|
+
throw new Error(
|
|
869
|
+
`This doesn't look like an HTML document (it looks like ${kind}). Pass the raw HTML as \`html\`, ` +
|
|
870
|
+
"or in the local edition pass `path` to the .html file — do NOT base64-encode it.",
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return { path: filename || "index.html", buffer: Buffer.from(content, "utf8"), type: "text/html" };
|
|
875
|
+
}
|
|
876
|
+
|
|
849
877
|
// Anonymous drops are owned by the platform Guest Org, whose slug is the apex
|
|
850
878
|
// the public URL hangs off (https://guest.cloudgrid.io/<slug>).
|
|
851
879
|
const GUEST_ORG_SLUG = "guest";
|
|
@@ -908,430 +936,6 @@ export function resolvePlugUrl(data) {
|
|
|
908
936
|
return composePlugUrl(data);
|
|
909
937
|
}
|
|
910
938
|
|
|
911
|
-
export async function runDrop(
|
|
912
|
-
ctx,
|
|
913
|
-
{ html, path: filePath, filename, anonymous, org, grid, fresh, entity_id, owner_token },
|
|
914
|
-
deps = {},
|
|
915
|
-
) {
|
|
916
|
-
// `grid` is an accepted alias for `org` (Gilad's org→grid rename); `org` still works.
|
|
917
|
-
const targetGrid = org ?? grid;
|
|
918
|
-
// The `deps` seam (readFile/exists) exists ONLY so the content-handling
|
|
919
|
-
// regression tests can drive the `path` / `@`-path routes offline. Production
|
|
920
|
-
// always calls this with no deps.
|
|
921
|
-
const { readFile: readFileImpl = readFile, fsExists = existsSync } = deps;
|
|
922
|
-
|
|
923
|
-
// Defensive: the web edition schema excludes `path`, but if a model still
|
|
924
|
-
// passes one (e.g. from a cached tool description), reject early with a
|
|
925
|
-
// clear explanation.
|
|
926
|
-
if (ctx.edition === "web" && filePath) {
|
|
927
|
-
throw new Error(
|
|
928
|
-
"The hosted server cannot read local files — pass the full document as `html` instead of a `path`.",
|
|
929
|
-
);
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// An agent may pass an `@`-prefixed path (invented `@/home/claude/...`
|
|
933
|
-
// shorthand) or a bare filesystem path as `html`. Normalise: strip a leading
|
|
934
|
-
// `@`, and in the local edition promote a path-looking `html` to the `path`
|
|
935
|
-
// route (read from disk) when the file exists. On the hosted server there is
|
|
936
|
-
// no disk, so a path-looking `html` is a clear error.
|
|
937
|
-
let effectivePath = filePath;
|
|
938
|
-
let htmlInput = typeof html === "string" ? html : undefined;
|
|
939
|
-
if (!effectivePath && typeof htmlInput === "string") {
|
|
940
|
-
let candidate = htmlInput;
|
|
941
|
-
if (candidate.startsWith("@")) candidate = candidate.slice(1);
|
|
942
|
-
if (!looksLikeFullHtml(candidate) && looksLikePath(candidate)) {
|
|
943
|
-
if (ctx.edition === "web") {
|
|
944
|
-
throw new Error(
|
|
945
|
-
`This looks like a file path (\`${candidate.trim()}\`), but the hosted server cannot read local ` +
|
|
946
|
-
`files — pass the raw HTML inline as \`html\`.`,
|
|
947
|
-
);
|
|
948
|
-
}
|
|
949
|
-
const resolvedPath = candidate.trim();
|
|
950
|
-
if (fsExists(resolvedPath)) {
|
|
951
|
-
effectivePath = resolvedPath;
|
|
952
|
-
htmlInput = undefined;
|
|
953
|
-
} else {
|
|
954
|
-
throw new Error(
|
|
955
|
-
`The path \`${resolvedPath}\` does not exist. Pass a valid \`path\` to the .html file, or pass ` +
|
|
956
|
-
`the raw HTML inline as \`html\` (do not base64-encode it, and there is no artifact_files parameter).`,
|
|
957
|
-
);
|
|
958
|
-
}
|
|
959
|
-
} else if (candidate !== htmlInput) {
|
|
960
|
-
// Leading `@` on genuine inline content — strip it and keep the content.
|
|
961
|
-
htmlInput = candidate;
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
let bytes;
|
|
966
|
-
let name;
|
|
967
|
-
let type;
|
|
968
|
-
// Anonymous drops are inline-capped hard; signed-in drops get a larger cap.
|
|
969
|
-
// The cap is enforced AFTER auth is resolved (see below), so only inline
|
|
970
|
-
// routes flag themselves here — `path` (local, read from disk) is uncapped.
|
|
971
|
-
let enforceInlineCap = false;
|
|
972
|
-
|
|
973
|
-
if (effectivePath) {
|
|
974
|
-
const raw = await readFileImpl(effectivePath);
|
|
975
|
-
const asText = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
|
976
|
-
const { html: decoded, wasBase64 } = decodeIfBase64Html(asText);
|
|
977
|
-
const lowerName = String(effectivePath).toLowerCase();
|
|
978
|
-
const nameIsHtml = lowerName.endsWith(".html") || lowerName.endsWith(".htm");
|
|
979
|
-
if (wasBase64) {
|
|
980
|
-
// A base64 `.txt` (or similar) that decoded to real HTML → publish the
|
|
981
|
-
// decoded HTML as text/html under an index.html name.
|
|
982
|
-
bytes = Buffer.from(decoded, "utf8");
|
|
983
|
-
type = "text/html";
|
|
984
|
-
name = filename || (nameIsHtml ? basename(effectivePath) : "index.html");
|
|
985
|
-
} else {
|
|
986
|
-
bytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
987
|
-
// Content-type sniff (fix #4): serve HTML as text/html so it renders as a
|
|
988
|
-
// page instead of downloading as a blob.
|
|
989
|
-
if (looksLikeFullHtml(asText) || nameIsHtml) {
|
|
990
|
-
type = "text/html";
|
|
991
|
-
name = filename || (nameIsHtml ? basename(effectivePath) : "index.html");
|
|
992
|
-
} else {
|
|
993
|
-
type = "application/octet-stream";
|
|
994
|
-
name = filename || basename(effectivePath);
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
} else if (typeof htmlInput === "string" && htmlInput.length > 0) {
|
|
998
|
-
// Rescue a base64-of-HTML blob passed as `html` (real user repro).
|
|
999
|
-
const { html: resolved } = decodeIfBase64Html(htmlInput);
|
|
1000
|
-
let content = resolved;
|
|
1001
|
-
if (!looksLikeFullHtml(content)) {
|
|
1002
|
-
const stripped = content.replace(/\s+/g, "");
|
|
1003
|
-
const looksBase64 =
|
|
1004
|
-
stripped.length >= 64 && stripped.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(stripped);
|
|
1005
|
-
const isShortFragment = Buffer.byteLength(content, "utf8") <= 8192;
|
|
1006
|
-
const hasTag = /<[a-z][\s\S]*>/i.test(content);
|
|
1007
|
-
if (isShortFragment && (hasTag || (!looksBase64 && !looksLikePath(content)))) {
|
|
1008
|
-
// Legit "share this snippet" — wrap a small text/markup fragment into a
|
|
1009
|
-
// full document (preserve existing friendly behavior).
|
|
1010
|
-
content =
|
|
1011
|
-
`<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
|
|
1012
|
-
`<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
|
|
1013
|
-
} else {
|
|
1014
|
-
// Large, or base64 that failed to decode to HTML, or a bare file path:
|
|
1015
|
-
// refuse instead of silently publishing garbage.
|
|
1016
|
-
const kind = looksBase64
|
|
1017
|
-
? "base64"
|
|
1018
|
-
: looksLikePath(content)
|
|
1019
|
-
? "a file path"
|
|
1020
|
-
: "raw data";
|
|
1021
|
-
throw new Error(
|
|
1022
|
-
`This doesn't look like an HTML document (it looks like ${kind}). Pass the raw HTML as \`html\`, ` +
|
|
1023
|
-
`or in the local edition pass \`path\` to the .html file — do NOT base64-encode it. ` +
|
|
1024
|
-
`There is no artifact_files parameter.`,
|
|
1025
|
-
);
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
bytes = Buffer.from(content, "utf8");
|
|
1029
|
-
name = filename || "index.html";
|
|
1030
|
-
type = "text/html";
|
|
1031
|
-
enforceInlineCap = true;
|
|
1032
|
-
} else {
|
|
1033
|
-
throw new Error("Provide either `html` (inline content) or `path` (a local file).");
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// The caller's authed identity (unless they force an anonymous drop).
|
|
1037
|
-
let authToken = null;
|
|
1038
|
-
let orgSlug = null;
|
|
1039
|
-
if (anonymous !== true) {
|
|
1040
|
-
authToken = await ctx.getToken();
|
|
1041
|
-
if (authToken) {
|
|
1042
|
-
orgSlug = targetGrid || (await ctx.getActiveGrid());
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
// ── Re-plug targeting (unified plug contract / spec v2) ──────
|
|
1047
|
-
// A `target_entity_id` on the wire UPDATES THAT SAME ENTITY in place — same
|
|
1048
|
-
// entity_id, same slug, same URL, deploy history preserved. Absent → create.
|
|
1049
|
-
// The session remembers the last drop, so a re-drop updates it by default;
|
|
1050
|
-
// `fresh: true` forces a create; an explicit `entity_id` targets any earlier
|
|
1051
|
-
// drop (the durable re-plug handle a stateless caller persisted).
|
|
1052
|
-
if (fresh === true && entity_id) {
|
|
1053
|
-
throw new Error("`fresh: true` forces a new drop — do not pass `entity_id` with it.");
|
|
1054
|
-
}
|
|
1055
|
-
const sessionDrop = ctx.state.lastDrop;
|
|
1056
|
-
let targetId = null;
|
|
1057
|
-
let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
|
|
1058
|
-
if (fresh !== true) {
|
|
1059
|
-
targetId = entity_id || sessionDrop?.entity_id || null;
|
|
1060
|
-
// An anon-minted drop is edited with its OWNER TOKEN (a bearer capability,
|
|
1061
|
-
// the anon owner-token contract). Recover it from session state when not passed.
|
|
1062
|
-
if (targetId && !ownerToken) {
|
|
1063
|
-
if (sessionDrop?.entity_id === targetId && sessionDrop.owner_token) {
|
|
1064
|
-
ownerToken = sessionDrop.owner_token;
|
|
1065
|
-
} else if (ctx.state.lastAnonClaim?.entity_id === targetId && ctx.state.lastAnonClaim.token) {
|
|
1066
|
-
ownerToken = ctx.state.lastAnonClaim.token;
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
// Pick the edit wire. An anon-owned (Guest-Grid, unclaimed) drop is edited via
|
|
1072
|
-
// the owner-token wire EVEN IF the caller is signed in — the entity still lives
|
|
1073
|
-
// in the Guest Grid, so an authed edit of it would 404. An authed edit targets
|
|
1074
|
-
// an entity the caller owns in their grid.
|
|
1075
|
-
let mode = "create"; // "create" | "authed-edit" | "anon-edit"
|
|
1076
|
-
if (targetId) {
|
|
1077
|
-
if (ownerToken) {
|
|
1078
|
-
mode = "anon-edit";
|
|
1079
|
-
} else if (authToken) {
|
|
1080
|
-
mode = "authed-edit";
|
|
1081
|
-
} else if (entity_id) {
|
|
1082
|
-
throw new Error(
|
|
1083
|
-
"Re-plugging a drop anonymously needs its owner_token (returned when it was created). " +
|
|
1084
|
-
"Pass owner_token, sign in if you own it, or pass fresh: true to publish a new drop.",
|
|
1085
|
-
);
|
|
1086
|
-
} else {
|
|
1087
|
-
// Session target, but no way to authorize an edit — fall back to create.
|
|
1088
|
-
targetId = null;
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
const isAnonymousWire = mode === "anon-edit" || !authToken;
|
|
1092
|
-
|
|
1093
|
-
// Auth-aware inline size cap (relocated here — auth is only known now). Anon
|
|
1094
|
-
// inline drops stay capped at 2MB; signed-in inline drops get the larger
|
|
1095
|
-
// AUTHED cap. `path` (read from disk) is uncapped and does not set the flag.
|
|
1096
|
-
if (enforceInlineCap) {
|
|
1097
|
-
if (isAnonymousWire) {
|
|
1098
|
-
if (bytes.byteLength > ANON_HTML_MAX_BYTES) {
|
|
1099
|
-
throw new Error(
|
|
1100
|
-
`This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
|
|
1101
|
-
`Trim it, or sign in to publish larger.`,
|
|
1102
|
-
);
|
|
1103
|
-
}
|
|
1104
|
-
} else if (bytes.byteLength > AUTHED_HTML_MAX_BYTES) {
|
|
1105
|
-
throw new Error(
|
|
1106
|
-
`This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
|
|
1107
|
-
`${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
|
|
1108
|
-
`.html file (read from disk — no inline size limit) instead of pasting it inline.`,
|
|
1109
|
-
);
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
const headers = {};
|
|
1114
|
-
if (!isAnonymousWire) {
|
|
1115
|
-
headers["Authorization"] = `Bearer ${authToken}`;
|
|
1116
|
-
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1117
|
-
if (orgSlug) {
|
|
1118
|
-
headers["X-CloudGrid-Grid"] = orgSlug;
|
|
1119
|
-
headers["X-CloudGrid-Org"] = orgSlug;
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
// Hosted server: attach the trusted-server credential on EVERY anonymous call
|
|
1124
|
-
// (creates AND owner-token edits — anon edits consume the same daily anon cap,
|
|
1125
|
-
// re-keyed per end user). Falls back gracefully server-side if absent. Only
|
|
1126
|
-
// the web edition sets ctx.trustedServer.
|
|
1127
|
-
if (isAnonymousWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
|
|
1128
|
-
headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
|
|
1129
|
-
headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// Ownership continuity: replay the platform's anon-session cookie across drops in
|
|
1133
|
-
// this session, so cookie-class callers can redrop (and claim) what they dropped.
|
|
1134
|
-
if (isAnonymousWire && ctx.state.anonCookie) {
|
|
1135
|
-
headers["Cookie"] = ctx.state.anonCookie;
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
const form = new FormData();
|
|
1139
|
-
// The artifact part name is unchanged from `/drop/auto` (`artifact`); the plug
|
|
1140
|
-
// create path — and the inspiration edit paths — treat every
|
|
1141
|
-
// non-`cloudgrid.yaml` part as raw artifact bytes.
|
|
1142
|
-
form.append("artifact", new Blob([bytes], { type }), name);
|
|
1143
|
-
if (mode !== "create") {
|
|
1144
|
-
form.append("target_entity_id", targetId);
|
|
1145
|
-
}
|
|
1146
|
-
if (mode === "anon-edit") {
|
|
1147
|
-
// Anon owner-token contract: ownership is proven by HOLDING the token (a form
|
|
1148
|
-
// field, NOT an Authorization header). The response re-mints it.
|
|
1149
|
-
form.append("owner_token", ownerToken);
|
|
1150
|
-
}
|
|
1151
|
-
if (mode === "authed-edit") {
|
|
1152
|
-
// The authed update path requires a `cloudgrid.yaml` part on the wire
|
|
1153
|
-
// (materializePlugTarball). An inspiration edit ignores its content, so an
|
|
1154
|
-
// empty part satisfies the contract without changing the entity's config.
|
|
1155
|
-
form.append("cloudgrid.yaml", new Blob([""], { type: "text/plain" }), "cloudgrid.yaml");
|
|
1156
|
-
}
|
|
1157
|
-
// `/plug` resolves the authed org from the `X-CloudGrid-Org` header (set
|
|
1158
|
-
// above), not a form field — so `org_slug` is no longer sent.
|
|
1159
|
-
|
|
1160
|
-
let res;
|
|
1161
|
-
try {
|
|
1162
|
-
res = await fetch(`${API_BASE}/api/v2/plug`, { method: "POST", headers, body: form });
|
|
1163
|
-
} catch (err) {
|
|
1164
|
-
throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
const raw = await res.text();
|
|
1168
|
-
let data = null;
|
|
1169
|
-
try {
|
|
1170
|
-
data = JSON.parse(raw);
|
|
1171
|
-
} catch {
|
|
1172
|
-
/* handled below */
|
|
1173
|
-
}
|
|
1174
|
-
if (!res.ok) {
|
|
1175
|
-
const code = data?.error?.code;
|
|
1176
|
-
const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
|
|
1177
|
-
const hint = data?.error?.details?.[0]?.hint;
|
|
1178
|
-
const isEdit = mode !== "create";
|
|
1179
|
-
// Self-heal rung: a signed-in CREATE that hits the known 400 SCOPE_INVALID
|
|
1180
|
-
// platform bug is retried through the bundled CLI — LOCAL edition only,
|
|
1181
|
-
// create only, never anonymous. (Mirrors runPlug.)
|
|
1182
|
-
if (
|
|
1183
|
-
res.status === 400 &&
|
|
1184
|
-
code === "SCOPE_INVALID" &&
|
|
1185
|
-
ctx.edition === "local" &&
|
|
1186
|
-
mode === "create" &&
|
|
1187
|
-
!isAnonymousWire &&
|
|
1188
|
-
authToken
|
|
1189
|
-
) {
|
|
1190
|
-
return plugViaCliFallback(ctx, [{ path: name, buffer: bytes }], deps);
|
|
1191
|
-
}
|
|
1192
|
-
// An explicit edit NEVER silently creates — surface the reason clearly.
|
|
1193
|
-
if (isEdit && res.status === 409) {
|
|
1194
|
-
throw new Error(
|
|
1195
|
-
`Re-plug rejected (HTTP 409): ${msg} ` +
|
|
1196
|
-
"The drop can no longer be edited in place (expired, archived, claimed, or a deploy is running). " +
|
|
1197
|
-
"Pass fresh: true to publish a new drop instead.",
|
|
1198
|
-
);
|
|
1199
|
-
}
|
|
1200
|
-
if (mode === "anon-edit" && res.status === 401) {
|
|
1201
|
-
throw new Error(
|
|
1202
|
-
`Re-plug rejected (HTTP 401): ${msg} ` +
|
|
1203
|
-
"The owner_token did not authorize this entity (wrong entity, expired with the drop, or already claimed). " +
|
|
1204
|
-
"Pass fresh: true to publish a new drop instead.",
|
|
1205
|
-
);
|
|
1206
|
-
}
|
|
1207
|
-
// Known-code guidance (SCOPE_INVALID on web, 429 anon cap, etc.); unknown
|
|
1208
|
-
// codes pass through as the bare error line, unchanged.
|
|
1209
|
-
const guidance = errorGuidance({
|
|
1210
|
-
status: res.status,
|
|
1211
|
-
code,
|
|
1212
|
-
edition: ctx.edition,
|
|
1213
|
-
isEdit,
|
|
1214
|
-
isAnon: isAnonymousWire,
|
|
1215
|
-
signedIn: Boolean(authToken),
|
|
1216
|
-
});
|
|
1217
|
-
const baseLine = `Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`;
|
|
1218
|
-
throw new Error(guidance ? `${baseLine} — ${guidance}` : baseLine);
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
// Persist the platform's anon-session cookie for ownership continuity (so a
|
|
1222
|
-
// cookie-class caller can claim — via pickup — what it dropped).
|
|
1223
|
-
const setCookies = res.headers.getSetCookie
|
|
1224
|
-
? res.headers.getSetCookie()
|
|
1225
|
-
: [res.headers.get("set-cookie")].filter(Boolean);
|
|
1226
|
-
const anonCookie = (setCookies || [])
|
|
1227
|
-
.map((c) => (c || "").split(";")[0])
|
|
1228
|
-
.find((c) => c.startsWith("cg_anon_session="));
|
|
1229
|
-
if (anonCookie) ctx.state.anonCookie = anonCookie;
|
|
1230
|
-
|
|
1231
|
-
// The server composes the canonical URL; compose client-side only as
|
|
1232
|
-
// a fallback when it came back empty.
|
|
1233
|
-
const url = resolvePlugUrl(data);
|
|
1234
|
-
const isEdit = mode !== "create";
|
|
1235
|
-
|
|
1236
|
-
// The anon owner token: the create response mints it; every anon edit
|
|
1237
|
-
// RE-MINTS it to the reset TTL (replace the stored one — the freshest token
|
|
1238
|
-
// is the one that lives as long as the drop). Same JWT the claim uses.
|
|
1239
|
-
let freshOwnerToken = typeof data.owner_token === "string" && data.owner_token.length > 0
|
|
1240
|
-
? data.owner_token
|
|
1241
|
-
: null;
|
|
1242
|
-
if (!freshOwnerToken && data.claim_url) {
|
|
1243
|
-
try {
|
|
1244
|
-
freshOwnerToken = new URL(data.claim_url).searchParams.get("token");
|
|
1245
|
-
} catch {
|
|
1246
|
-
freshOwnerToken = null;
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
// Remember the drop for session continuity — any caller class. The
|
|
1251
|
-
// `{entity_id, owner_token}` pair is the stateless anon re-plug handle.
|
|
1252
|
-
if (data.entity_id || url) {
|
|
1253
|
-
ctx.state.lastDrop = {
|
|
1254
|
-
entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
|
|
1255
|
-
url: url ?? ctx.state.lastDrop?.url ?? null,
|
|
1256
|
-
owner_token: isAnonymousWire
|
|
1257
|
-
? (freshOwnerToken ?? (mode === "anon-edit" ? ownerToken : null))
|
|
1258
|
-
: null,
|
|
1259
|
-
};
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
// Authenticated wire (create or in-place edit): the entity is owned by the caller.
|
|
1263
|
-
if (!isAnonymousWire) {
|
|
1264
|
-
ctx.state.lastAnonClaim = null;
|
|
1265
|
-
const structured = {
|
|
1266
|
-
url,
|
|
1267
|
-
status: isEdit ? "updated" : "created",
|
|
1268
|
-
owned_by: "authenticated",
|
|
1269
|
-
...(data.entity_id ? { entity_id: data.entity_id } : {}),
|
|
1270
|
-
};
|
|
1271
|
-
const lines = [];
|
|
1272
|
-
if (isEdit) {
|
|
1273
|
-
lines.push(`Updated in place: ${url}`);
|
|
1274
|
-
lines.push("Same link, new content. The expiry timer was reset.");
|
|
1275
|
-
} else if (ctx.edition === "web") {
|
|
1276
|
-
lines.push(`Your app is live: ${url}`);
|
|
1277
|
-
} else {
|
|
1278
|
-
lines.push(`Published to your org: ${url}`, "Owned by you.");
|
|
1279
|
-
}
|
|
1280
|
-
if (ctx.edition === "web" && !isEdit) {
|
|
1281
|
-
// Default authed web CREATES to "link" visibility so the URL is shareable
|
|
1282
|
-
// and the console thumbnail renders without a sign-in wall. An edit keeps
|
|
1283
|
-
// the entity's existing visibility untouched.
|
|
1284
|
-
if (data.entity_id) {
|
|
1285
|
-
await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
|
|
1286
|
-
}
|
|
1287
|
-
lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
|
|
1288
|
-
const vis = "link";
|
|
1289
|
-
lines.push(`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`);
|
|
1290
|
-
structured.console_url = CONSOLE_URL;
|
|
1291
|
-
structured.current_visibility = vis;
|
|
1292
|
-
structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
|
|
1293
|
-
}
|
|
1294
|
-
return { text: lines.join("\n"), structured };
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
// Anonymous wire — Guest-Grid inspiration (create 201, or owner-token edit).
|
|
1298
|
-
// The response carries the reward fields (`claim_url` + `claim_message`), the
|
|
1299
|
-
// real `entity_id`, and the OWNER TOKEN — the bearer capability that
|
|
1300
|
-
// authorizes BOTH a later anonymous re-plug and the claim (pickup-by-id).
|
|
1301
|
-
if (data.claim_url || data.entity_id) {
|
|
1302
|
-
ctx.state.lastAnonClaim = {
|
|
1303
|
-
token: freshOwnerToken,
|
|
1304
|
-
entity_id: data.entity_id ?? ctx.state.lastAnonClaim?.entity_id ?? null,
|
|
1305
|
-
url,
|
|
1306
|
-
};
|
|
1307
|
-
}
|
|
1308
|
-
const lines = [];
|
|
1309
|
-
if (isEdit) {
|
|
1310
|
-
lines.push(`Updated in place: ${url}`);
|
|
1311
|
-
lines.push("Same link, new content. The expiry timer (and owner_token) were refreshed.");
|
|
1312
|
-
} else {
|
|
1313
|
-
lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Live: ${url}`);
|
|
1314
|
-
}
|
|
1315
|
-
if (data.claim_message) {
|
|
1316
|
-
lines.push(data.claim_message);
|
|
1317
|
-
} else if (data.claim_url) {
|
|
1318
|
-
lines.push("Sign in, then run grid_claim to keep it past the expiry window.");
|
|
1319
|
-
}
|
|
1320
|
-
if (data.entity_id && freshOwnerToken) {
|
|
1321
|
-
lines.push(
|
|
1322
|
-
`Re-plug handle (persist to update or claim this drop in a later session): entity_id=${data.entity_id}`,
|
|
1323
|
-
);
|
|
1324
|
-
}
|
|
1325
|
-
return {
|
|
1326
|
-
text: lines.join("\n"),
|
|
1327
|
-
structured: {
|
|
1328
|
-
url,
|
|
1329
|
-
status: isEdit ? "updated" : "created",
|
|
1330
|
-
...(data.entity_id ? { entity_id: data.entity_id } : {}),
|
|
1331
|
-
...(freshOwnerToken ? { owner_token: freshOwnerToken } : {}),
|
|
1332
|
-
},
|
|
1333
|
-
};
|
|
1334
|
-
}
|
|
1335
939
|
|
|
1336
940
|
// ── Consent-gated error reporting (Task 34 / 0.8.1) ──────────────────────────
|
|
1337
941
|
// Key names that look like they carry a secret. Mirrors the server's
|
|
@@ -1873,6 +1477,32 @@ export async function plugViaCliFallback(ctx, artifacts, deps = {}) {
|
|
|
1873
1477
|
}
|
|
1874
1478
|
}
|
|
1875
1479
|
|
|
1480
|
+
// After an authenticated WEB inspiration create, upgrade visibility to "link" so
|
|
1481
|
+
// the page is shareable and its preview renders without a sign-in wall — the
|
|
1482
|
+
// whole point of the hosted "share a link with a friend" flow. Best-effort: a
|
|
1483
|
+
// failure here never fails the publish (the user can always call grid_visibility).
|
|
1484
|
+
async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
|
|
1485
|
+
const token = await ctx.getToken();
|
|
1486
|
+
if (!token || !entityId) return false;
|
|
1487
|
+
try {
|
|
1488
|
+
const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
1489
|
+
// Grid-native header + X-CloudGrid-Org alias (same value) during the soak.
|
|
1490
|
+
// Never send conflicting values → 400 GRID_HEADER_CONFLICT.
|
|
1491
|
+
if (orgSlug) {
|
|
1492
|
+
hdrs["X-CloudGrid-Grid"] = orgSlug;
|
|
1493
|
+
hdrs["X-CloudGrid-Org"] = orgSlug;
|
|
1494
|
+
}
|
|
1495
|
+
const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
|
|
1496
|
+
method: "PATCH",
|
|
1497
|
+
headers: hdrs,
|
|
1498
|
+
body: JSON.stringify({ visibility: "link" }),
|
|
1499
|
+
});
|
|
1500
|
+
return res.ok;
|
|
1501
|
+
} catch {
|
|
1502
|
+
return false;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1876
1506
|
/**
|
|
1877
1507
|
* The unified create/re-plug verb (spec v2 §3). Two intents on one tool, keyed
|
|
1878
1508
|
* by `target_entity_id`:
|
|
@@ -1889,27 +1519,46 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1889
1519
|
const {
|
|
1890
1520
|
path: srcPath,
|
|
1891
1521
|
artifact_files,
|
|
1522
|
+
html,
|
|
1523
|
+
filename,
|
|
1892
1524
|
cloudgrid_yaml,
|
|
1893
1525
|
target_entity_id,
|
|
1894
1526
|
grid,
|
|
1527
|
+
slug,
|
|
1895
1528
|
hints,
|
|
1896
1529
|
anon,
|
|
1897
1530
|
owner_token,
|
|
1898
1531
|
} = input || {};
|
|
1899
1532
|
|
|
1900
|
-
// ── Source:
|
|
1901
|
-
|
|
1902
|
-
|
|
1533
|
+
// ── Source: exactly one of html | artifact_files | path ─────────────────────
|
|
1534
|
+
const hasHtml = typeof html === "string" && html.length > 0;
|
|
1535
|
+
const hasArtifacts = Array.isArray(artifact_files) && artifact_files.length > 0;
|
|
1536
|
+
const hasPath = Boolean(srcPath);
|
|
1537
|
+
if ((hasHtml ? 1 : 0) + (hasArtifacts ? 1 : 0) + (hasPath ? 1 : 0) > 1) {
|
|
1538
|
+
throw new Error(
|
|
1539
|
+
"Pass exactly one source: `html` (a single inline HTML document), `artifact_files` " +
|
|
1540
|
+
"(multiple inline files), or `path` (a local file/folder).",
|
|
1541
|
+
);
|
|
1903
1542
|
}
|
|
1904
|
-
if (ctx.edition === "web" &&
|
|
1543
|
+
if (ctx.edition === "web" && hasPath) {
|
|
1905
1544
|
throw new Error(
|
|
1906
|
-
"The hosted server cannot read local files — pass the source inline via `artifact_files`.",
|
|
1545
|
+
"The hosted server cannot read local files — pass the source inline via `html` or `artifact_files`.",
|
|
1907
1546
|
);
|
|
1908
1547
|
}
|
|
1909
1548
|
let artifacts;
|
|
1910
|
-
|
|
1549
|
+
// Set on the single-file `html` path so the auth-aware inline size cap can be
|
|
1550
|
+
// enforced once the anon-vs-authed wire is known (see below).
|
|
1551
|
+
let inlineHtmlBytes = null;
|
|
1552
|
+
if (hasHtml) {
|
|
1553
|
+
// The ergonomic single-file publish path (the old drop verb): one self-
|
|
1554
|
+
// contained HTML document → one index.html artifact, with the shared
|
|
1555
|
+
// hardening (base64 rescue, @-path/file-path rejection, fragment wrap).
|
|
1556
|
+
const art = htmlToArtifact(html, filename);
|
|
1557
|
+
inlineHtmlBytes = art.buffer.byteLength;
|
|
1558
|
+
artifacts = [art];
|
|
1559
|
+
} else if (hasPath) {
|
|
1911
1560
|
artifacts = collectPathArtifacts(srcPath);
|
|
1912
|
-
} else if (
|
|
1561
|
+
} else if (hasArtifacts) {
|
|
1913
1562
|
let total = 0;
|
|
1914
1563
|
artifacts = artifact_files.map((f) => {
|
|
1915
1564
|
if (!f || typeof f.path !== "string" || typeof f.content !== "string") {
|
|
@@ -1925,12 +1574,25 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1925
1574
|
} else {
|
|
1926
1575
|
throw new Error(
|
|
1927
1576
|
ctx.edition === "web"
|
|
1928
|
-
? "Provide the source via `artifact_files`."
|
|
1929
|
-
: "Provide the source via `
|
|
1577
|
+
? "Provide the source via `html` (a single inline HTML document) or `artifact_files`."
|
|
1578
|
+
: "Provide the source via `html` (a single inline HTML document), `path` (a local file " +
|
|
1579
|
+
"or folder), or `artifact_files`.",
|
|
1930
1580
|
);
|
|
1931
1581
|
}
|
|
1932
1582
|
|
|
1933
|
-
|
|
1583
|
+
// grid+slug re-plug handle (the pickup contract's `replug_handle`): when no
|
|
1584
|
+
// explicit target_entity_id was given but a grid+slug pair is, resolve it to
|
|
1585
|
+
// an existing entity_id and re-plug that in place. A slug that does NOT resolve
|
|
1586
|
+
// to an existing entity → targetEntityId stays empty → this is a CREATE (no
|
|
1587
|
+
// false-positive re-plug). target_entity_id remains the primary/documented
|
|
1588
|
+
// handle. Best-effort resolve (pickup contract); never fetches the public URL.
|
|
1589
|
+
let targetEntityId = target_entity_id;
|
|
1590
|
+
if ((typeof targetEntityId !== "string" || targetEntityId.length === 0) && grid && slug) {
|
|
1591
|
+
const resolved = await resolveEntityViaPickup(ctx, { target: slug, grid });
|
|
1592
|
+
if (resolved?.entity_id) targetEntityId = resolved.entity_id;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const isEdit = typeof targetEntityId === "string" && targetEntityId.length > 0;
|
|
1934
1596
|
|
|
1935
1597
|
// An inspiration edit content-versions the FIRST uploaded artifact — when a
|
|
1936
1598
|
// multi-file folder rides a re-plug, put the primary entry first so the edit
|
|
@@ -1950,10 +1612,10 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1950
1612
|
if (isEdit && !ownerToken) {
|
|
1951
1613
|
// Recover the owner token from session state when re-plugging the drop this
|
|
1952
1614
|
// session made anonymously.
|
|
1953
|
-
if (ctx.state.lastDrop?.entity_id ===
|
|
1615
|
+
if (ctx.state.lastDrop?.entity_id === targetEntityId && ctx.state.lastDrop.owner_token) {
|
|
1954
1616
|
ownerToken = ctx.state.lastDrop.owner_token;
|
|
1955
1617
|
} else if (
|
|
1956
|
-
ctx.state.lastAnonClaim?.entity_id ===
|
|
1618
|
+
ctx.state.lastAnonClaim?.entity_id === targetEntityId &&
|
|
1957
1619
|
ctx.state.lastAnonClaim.token
|
|
1958
1620
|
) {
|
|
1959
1621
|
ownerToken = ctx.state.lastAnonClaim.token;
|
|
@@ -1970,7 +1632,30 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1970
1632
|
);
|
|
1971
1633
|
}
|
|
1972
1634
|
|
|
1635
|
+
// Auth-aware inline size cap for the single-file `html` path (the old drop
|
|
1636
|
+
// cap). Anonymous inline pages stay capped at 2 MB; signed-in inline pages get
|
|
1637
|
+
// the larger AUTHED cap. `path` (read from disk) and `artifact_files` are
|
|
1638
|
+
// bounded by PLUG_MAX_TOTAL_BYTES instead, so they never set inlineHtmlBytes.
|
|
1639
|
+
if (inlineHtmlBytes != null) {
|
|
1640
|
+
if (useAnonWire) {
|
|
1641
|
+
if (inlineHtmlBytes > ANON_HTML_MAX_BYTES) {
|
|
1642
|
+
throw new Error(
|
|
1643
|
+
`This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
|
|
1644
|
+
"Trim it, or sign in to publish larger.",
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
} else if (inlineHtmlBytes > AUTHED_HTML_MAX_BYTES) {
|
|
1648
|
+
throw new Error(
|
|
1649
|
+
`This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
|
|
1650
|
+
`${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
|
|
1651
|
+
".html file (read from disk — no inline size limit) instead of pasting it inline.",
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1973
1656
|
const headers = {};
|
|
1657
|
+
// The authed target grid — also reused for the post-create visibility upgrade.
|
|
1658
|
+
let orgSlug = null;
|
|
1974
1659
|
if (!useAnonWire && authToken) {
|
|
1975
1660
|
headers["Authorization"] = `Bearer ${authToken}`;
|
|
1976
1661
|
// On create, `grid` picks where the entity lands. On re-plug the entity's
|
|
@@ -1978,7 +1663,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1978
1663
|
// the caller's membership from this header and requires it to MATCH the
|
|
1979
1664
|
// entity's grid, so pass `grid` here too when the target lives outside the
|
|
1980
1665
|
// active grid.
|
|
1981
|
-
|
|
1666
|
+
orgSlug = grid || (await ctx.getActiveGrid());
|
|
1982
1667
|
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1983
1668
|
if (orgSlug) {
|
|
1984
1669
|
headers["X-CloudGrid-Grid"] = orgSlug;
|
|
@@ -2012,10 +1697,13 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2012
1697
|
// ── Wire assembly ───────────────────────────────────────────────────────────
|
|
2013
1698
|
const form = new FormData();
|
|
2014
1699
|
for (const a of artifacts) {
|
|
2015
|
-
|
|
1700
|
+
// Folder-walk / artifact_files parts ride as octet-stream (server sniffs by
|
|
1701
|
+
// name); the single-file `html` path carries text/html so a bare inline page
|
|
1702
|
+
// renders instead of downloading (the old drop behavior).
|
|
1703
|
+
form.append("artifact", new Blob([a.buffer], { type: a.type || "application/octet-stream" }), a.path);
|
|
2016
1704
|
}
|
|
2017
1705
|
if (isEdit) {
|
|
2018
|
-
form.append("target_entity_id",
|
|
1706
|
+
form.append("target_entity_id", targetEntityId);
|
|
2019
1707
|
if (useAnonWire) {
|
|
2020
1708
|
form.append("owner_token", ownerToken);
|
|
2021
1709
|
} else {
|
|
@@ -2086,7 +1774,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2086
1774
|
throw new Error(plugErrorMessage(res.status, code, msg, flags));
|
|
2087
1775
|
}
|
|
2088
1776
|
|
|
2089
|
-
// Anon-session cookie continuity
|
|
1777
|
+
// Anon-session cookie continuity.
|
|
2090
1778
|
const setCookies = res.headers.getSetCookie
|
|
2091
1779
|
? res.headers.getSetCookie()
|
|
2092
1780
|
: [res.headers.get("set-cookie")].filter(Boolean);
|
|
@@ -2107,7 +1795,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2107
1795
|
}
|
|
2108
1796
|
}
|
|
2109
1797
|
|
|
2110
|
-
// Session continuity — the
|
|
1798
|
+
// Session continuity — remember the last plug for re-plug handles.
|
|
2111
1799
|
if (data.entity_id || url) {
|
|
2112
1800
|
ctx.state.lastDrop = {
|
|
2113
1801
|
entity_id: data.entity_id ?? null,
|
|
@@ -2146,6 +1834,20 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2146
1834
|
// the terminal states get the live wording; anything still building points at
|
|
2147
1835
|
// the poll_url and grid_status.
|
|
2148
1836
|
const isBuilding = data.status === "building" || Boolean(data.poll_url);
|
|
1837
|
+
|
|
1838
|
+
// An authed CREATE of an INSPIRATION (the single-file `html` path, or a server-
|
|
1839
|
+
// detected inspiration — NOT a runtime app/agent, NOT a build in flight, NOT an
|
|
1840
|
+
// anon guest drop). This is the case that (on the hosted edition) must be made
|
|
1841
|
+
// link-visible so the shared URL renders without a sign-in wall.
|
|
1842
|
+
const detectedKind = data.detection?.kind;
|
|
1843
|
+
const isInspirationCreate =
|
|
1844
|
+
!isEdit &&
|
|
1845
|
+
!useAnonWire &&
|
|
1846
|
+
!isBuilding &&
|
|
1847
|
+
detectedKind !== "app" &&
|
|
1848
|
+
detectedKind !== "agent" &&
|
|
1849
|
+
(inlineHtmlBytes != null || detectedKind === "inspiration");
|
|
1850
|
+
|
|
2149
1851
|
const lines = [];
|
|
2150
1852
|
if (isBuilding) {
|
|
2151
1853
|
lines.push(
|
|
@@ -2158,8 +1860,14 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2158
1860
|
? `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.`
|
|
2159
1861
|
: "Run grid_status until it is ready. Do not report it as live until then.",
|
|
2160
1862
|
);
|
|
1863
|
+
} else if (isEdit) {
|
|
1864
|
+
lines.push(`Updated in place: ${url}`);
|
|
1865
|
+
} else if (isInspirationCreate) {
|
|
1866
|
+
// Authed inspiration create — owned by the caller. Wording mirrors the drop verb.
|
|
1867
|
+
lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Published to your org: ${url}`);
|
|
1868
|
+
if (ctx.edition !== "web") lines.push("Owned by you.");
|
|
2161
1869
|
} else {
|
|
2162
|
-
lines.push(
|
|
1870
|
+
lines.push(`Live: ${url}`);
|
|
2163
1871
|
}
|
|
2164
1872
|
if (data.entity_id) {
|
|
2165
1873
|
lines.push(
|
|
@@ -2170,6 +1878,22 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2170
1878
|
if (isEdit && useAnonWire) {
|
|
2171
1879
|
lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
|
|
2172
1880
|
}
|
|
1881
|
+
|
|
1882
|
+
// Hosted "share a link with a friend": a signed-in WEB inspiration create is
|
|
1883
|
+
// upgraded to "link" visibility so its preview renders without a sign-in wall.
|
|
1884
|
+
// Scoped EXACTLY to web + create + inspiration — never runtime apps/agents,
|
|
1885
|
+
// edits, anon drops, or the local edition (which stays owned/org-scoped).
|
|
1886
|
+
if (isInspirationCreate && ctx.edition === "web" && data.entity_id) {
|
|
1887
|
+
await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
|
|
1888
|
+
const vis = "link";
|
|
1889
|
+
structured.console_url = CONSOLE_URL;
|
|
1890
|
+
structured.current_visibility = vis;
|
|
1891
|
+
structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
|
|
1892
|
+
lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
|
|
1893
|
+
lines.push(
|
|
1894
|
+
`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`,
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
2173
1897
|
return { text: lines.join("\n"), structured };
|
|
2174
1898
|
}
|
|
2175
1899
|
|
|
@@ -2331,8 +2055,10 @@ function isCloudgridHost(hostname) {
|
|
|
2331
2055
|
}
|
|
2332
2056
|
|
|
2333
2057
|
// Shape an HTML string into the grid_source result (shared by the API-read
|
|
2334
|
-
// and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES.
|
|
2335
|
-
|
|
2058
|
+
// and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES. `extra` carries
|
|
2059
|
+
// optional edition metadata resolved from the pickup contract (kind, single_html,
|
|
2060
|
+
// capabilities, replug_handle, source_download_url) — merged into structured.
|
|
2061
|
+
function shapeSourceResult(sourceUrl, entityId, htmlStr, extra = {}) {
|
|
2336
2062
|
const buf = Buffer.from(htmlStr, "utf-8");
|
|
2337
2063
|
const totalBytes = buf.length;
|
|
2338
2064
|
const truncated = totalBytes > SOURCE_MAX_BYTES;
|
|
@@ -2348,7 +2074,7 @@ function shapeSourceResult(sourceUrl, entityId, htmlStr) {
|
|
|
2348
2074
|
lines.push("", html);
|
|
2349
2075
|
return {
|
|
2350
2076
|
text: lines.join("\n"),
|
|
2351
|
-
structured: { url: sourceUrl, entity_id: entityId ?? null, bytes: totalBytes, truncated, html },
|
|
2077
|
+
structured: { url: sourceUrl, entity_id: entityId ?? null, bytes: totalBytes, truncated, html, ...extra },
|
|
2352
2078
|
};
|
|
2353
2079
|
}
|
|
2354
2080
|
|
|
@@ -2384,6 +2110,50 @@ async function readInspirationSourceViaApi(ctx, { entityId, slug, grid }) {
|
|
|
2384
2110
|
return null;
|
|
2385
2111
|
}
|
|
2386
2112
|
|
|
2113
|
+
// Resolve a public URL / grid+slug to a REAL entity_id (+ edition metadata) via
|
|
2114
|
+
// the deployed pickup contract (POST /api/v2/entities/:target/pickup — the same
|
|
2115
|
+
// endpoint runClaim uses). Used by runSource when a bare URL arrives with no
|
|
2116
|
+
// session entity_id (a fresh chat): the contract returns
|
|
2117
|
+
// { entity_id, slug, grid, kind, single_html, capabilities, replug_handle,
|
|
2118
|
+
// source_download_url, ... }
|
|
2119
|
+
// so the agent can re-plug the SAME entity in place. This is a metadata resolve
|
|
2120
|
+
// (no claim_token in the body → no ownership transfer). Best-effort: returns null
|
|
2121
|
+
// on any failure so the caller falls back to today's behavior — and NEVER fetches
|
|
2122
|
+
// the public *.cloudgrid.io URL (the hosted pod cannot egress to it).
|
|
2123
|
+
async function resolveEntityViaPickup(ctx, { target, url, grid }) {
|
|
2124
|
+
const pathSeg = target || url;
|
|
2125
|
+
if (!pathSeg) return null;
|
|
2126
|
+
let token = null;
|
|
2127
|
+
try { token = await ctx.getToken(); } catch { /* anonymous is fine */ }
|
|
2128
|
+
const headers = { "Content-Type": "application/json" };
|
|
2129
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
2130
|
+
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
2131
|
+
if (grid) {
|
|
2132
|
+
headers["X-CloudGrid-Grid"] = grid;
|
|
2133
|
+
headers["X-CloudGrid-Org"] = grid;
|
|
2134
|
+
}
|
|
2135
|
+
if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
|
|
2136
|
+
try {
|
|
2137
|
+
const res = await fetch(
|
|
2138
|
+
`${API_BASE}/api/v2/entities/${encodeURIComponent(pathSeg)}/pickup`,
|
|
2139
|
+
{
|
|
2140
|
+
method: "POST",
|
|
2141
|
+
headers,
|
|
2142
|
+
// Send the URL as a resolution key too (the contract accepts id, slug, or
|
|
2143
|
+
// a {url} body). NO claim_token → resolve only, never a claim.
|
|
2144
|
+
body: JSON.stringify(url ? { url } : {}),
|
|
2145
|
+
signal: AbortSignal.timeout(15_000),
|
|
2146
|
+
},
|
|
2147
|
+
);
|
|
2148
|
+
if (!res.ok) return null;
|
|
2149
|
+
const data = await res.json().catch(() => null);
|
|
2150
|
+
if (data && typeof data.entity_id === "string" && data.entity_id.length > 0) return data;
|
|
2151
|
+
return null;
|
|
2152
|
+
} catch {
|
|
2153
|
+
return null;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2387
2157
|
// Fetch a drop's/inspiration's current deployed HTML inline as text so an agent
|
|
2388
2158
|
// that lost the content can edit it and re-plug in place. Resolves the fetch URL
|
|
2389
2159
|
// (explicit url → session lastDrop → composed grid+slug), then reads the HTML
|
|
@@ -2422,19 +2192,40 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
|
|
|
2422
2192
|
}
|
|
2423
2193
|
|
|
2424
2194
|
const resolvedUrl = parsed.toString();
|
|
2425
|
-
|
|
2195
|
+
let eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
|
|
2426
2196
|
|
|
2427
|
-
//
|
|
2428
|
-
// Read the HTML from the API server-side rather than fetching the public URL.
|
|
2429
|
-
// The hosted MCP pod can reach the API but NOT the public *.cloudgrid.io
|
|
2430
|
-
// ingress ("fetch failed") — this is the fix for hosted edit-in-place. Derive
|
|
2431
|
-
// the grid/slug hint from the inspiration path URL (`grid.cloudgrid.io/slug`).
|
|
2197
|
+
// Derive the grid/slug hint from the inspiration path URL (`grid.cloudgrid.io/slug`).
|
|
2432
2198
|
const host = parsed.hostname;
|
|
2433
2199
|
const gridHint = grid ?? (host.endsWith(".cloudgrid.io") && !host.includes("--") && host.split(".").length === 3
|
|
2434
2200
|
? host.split(".")[0] : null);
|
|
2435
2201
|
const slugHint = slug ?? (parsed.pathname.replace(/^\/+/, "").split("/")[0] || null);
|
|
2202
|
+
|
|
2203
|
+
// ── URL → entity_id (fresh chat, no session) via the pickup contract ──────
|
|
2204
|
+
// When a bare URL/slug arrived with no known entity_id, resolve a REAL
|
|
2205
|
+
// entity_id (+ edition metadata) so the agent can re-plug in place. Best-
|
|
2206
|
+
// effort: a failure falls back to today's behavior (never regress the read),
|
|
2207
|
+
// and we NEVER fetch the public URL for resolution.
|
|
2208
|
+
let extra = {};
|
|
2209
|
+
if (!eid) {
|
|
2210
|
+
const pickup = await resolveEntityViaPickup(ctx, { target: slugHint, url: resolvedUrl, grid: gridHint });
|
|
2211
|
+
if (pickup?.entity_id) {
|
|
2212
|
+
eid = pickup.entity_id;
|
|
2213
|
+
extra = {
|
|
2214
|
+
...(pickup.kind ? { kind: pickup.kind } : {}),
|
|
2215
|
+
...(typeof pickup.single_html === "boolean" ? { single_html: pickup.single_html } : {}),
|
|
2216
|
+
...(pickup.capabilities ? { capabilities: pickup.capabilities } : {}),
|
|
2217
|
+
...(pickup.replug_handle ? { replug_handle: pickup.replug_handle } : {}),
|
|
2218
|
+
...(pickup.source_download_url ? { source_download_url: pickup.source_download_url } : {}),
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// ── API-first (reachable) ────────────────────────────────────────────────
|
|
2224
|
+
// Read the HTML from the API server-side rather than fetching the public URL.
|
|
2225
|
+
// The hosted MCP pod can reach the API but NOT the public *.cloudgrid.io
|
|
2226
|
+
// ingress ("fetch failed") — this is the fix for hosted edit-in-place.
|
|
2436
2227
|
const apiHtml = await readInspirationSourceViaApi(ctx, { entityId: eid, slug: slugHint, grid: gridHint });
|
|
2437
|
-
if (apiHtml != null) return shapeSourceResult(resolvedUrl, eid, apiHtml);
|
|
2228
|
+
if (apiHtml != null) return shapeSourceResult(resolvedUrl, eid, apiHtml, extra);
|
|
2438
2229
|
|
|
2439
2230
|
// ── Fallback: direct public fetch ────────────────────────────────────────
|
|
2440
2231
|
// Local edition has normal egress; last resort on hosted (e.g. a runtime app
|
|
@@ -2470,7 +2261,7 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
|
|
|
2470
2261
|
}
|
|
2471
2262
|
|
|
2472
2263
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
2473
|
-
return shapeSourceResult(resolvedUrl, eid, buf.toString("utf-8"));
|
|
2264
|
+
return shapeSourceResult(resolvedUrl, eid, buf.toString("utf-8"), extra);
|
|
2474
2265
|
}
|
|
2475
2266
|
|
|
2476
2267
|
// ── Registration ───────────────────────────────────────────────────────────────
|
|
@@ -2521,144 +2312,6 @@ export function registerTools(server, ctx) {
|
|
|
2521
2312
|
|
|
2522
2313
|
// ── Direct-API tools (both editions) ──────────────────────────────────────
|
|
2523
2314
|
|
|
2524
|
-
// Drop — both editions, but the input schema is edition-aware: the web
|
|
2525
|
-
// edition removes `path` (the cloud server cannot read local files) and
|
|
2526
|
-
// strengthens `html` so the model always pastes the full document inline.
|
|
2527
|
-
const dropReplugParams = {
|
|
2528
|
-
fresh: z.boolean().optional().describe("Force a brand-new drop (new URL) even if you already dropped in this session. Default: a re-drop updates the session's drop in place — same URL, new content."),
|
|
2529
|
-
entity_id: z.string().optional().describe("Re-plug a SPECIFIC entity by id (the durable handle a previous drop returned) — updates it in place, same URL. Defaults to this session's last drop."),
|
|
2530
|
-
owner_token: z.string().optional().describe("The owner token of an anonymously-created drop (returned when it was created; refreshed on every anonymous edit). Needed to re-plug or claim it from a new session."),
|
|
2531
|
-
};
|
|
2532
|
-
const dropInputSchema = ctx.edition === "web"
|
|
2533
|
-
? {
|
|
2534
|
-
html: z.string().optional().describe(
|
|
2535
|
-
"The COMPLETE HTML document to publish — paste the full file contents here (not a path). " +
|
|
2536
|
-
"For a game, include all HTML/CSS/JS inline so it runs standalone. " +
|
|
2537
|
-
"A fragment is wrapped into a full document automatically.",
|
|
2538
|
-
),
|
|
2539
|
-
filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
|
|
2540
|
-
anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
|
|
2541
|
-
grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
|
|
2542
|
-
org: z.string().optional().describe("Deprecated alias for `grid` (kept for compatibility). Prefer `grid`."),
|
|
2543
|
-
...dropReplugParams,
|
|
2544
|
-
}
|
|
2545
|
-
: {
|
|
2546
|
-
html: z.string().optional().describe("Inline HTML to publish (a small fragment is wrapped into a full document). Pass the RAW HTML — do NOT base64-encode it, and do NOT pass an `@`-prefixed path or a file path here. For a large or image-heavy document, use `path` instead."),
|
|
2547
|
-
path: z.string().optional().describe("Path to a local .html file to upload instead of inline HTML — read from disk with no inline size limit. Preferred for large or image-heavy documents. There is no `artifact_files` parameter."),
|
|
2548
|
-
filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
|
|
2549
|
-
anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
|
|
2550
|
-
grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
|
|
2551
|
-
org: z.string().optional().describe("Deprecated alias for `grid` (kept for compatibility). Prefer `grid`."),
|
|
2552
|
-
...dropReplugParams,
|
|
2553
|
-
};
|
|
2554
|
-
|
|
2555
|
-
reg(
|
|
2556
|
-
"grid_drop",
|
|
2557
|
-
{
|
|
2558
|
-
description: "Publish an HTML page or file to CloudGrid and get a public shareable URL. Use when the user wants to share, publish, send, or 'deploy' an artifact, or wants a link to send a friend. Re-drops in the same session UPDATE THE SAME entity in place — same link, new content, expiry reset (pass fresh: true to force a new drop, or entity_id to target a specific earlier drop). If signed in, it publishes into the user's grid as an owned inspiration; if not, it drops anonymously into the Guest Grid, claimable later — the result includes an entity_id + owner_token to persist as the re-plug/claim handle for later sessions. Drops expire per the platform default (7 days) unless claimed/owned; every in-place edit resets the timer. If you want to edit an existing drop but no longer have its HTML, call grid_source first to retrieve it, then re-plug with target_entity_id. For a large or image-heavy document in the local edition, pass `path` to the .html file (read from disk — no inline size limit); do NOT base64-encode the content and do NOT pass an `@`-prefixed path or a file path as `html`. There is no `artifact_files` parameter. Calls POST /api/v2/plug directly.",
|
|
2559
|
-
inputSchema: dropInputSchema,
|
|
2560
|
-
outputSchema: {
|
|
2561
|
-
url: z.string().optional().describe("The public URL of the drop (stable across re-drops of the same entity)."),
|
|
2562
|
-
status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
|
|
2563
|
-
entity_id: z.string().optional().describe("The entity's durable id — pass back as entity_id to update it in place later."),
|
|
2564
|
-
owner_token: z.string().optional().describe("Anonymous drops only: the bearer owner token for later re-plug/claim. Refreshed on every anonymous edit — always persist the newest one."),
|
|
2565
|
-
owned_by: z.string().optional().describe("Ownership class, e.g. 'authenticated'."),
|
|
2566
|
-
expires_at: z.string().optional().describe("Expiry timestamp, if any."),
|
|
2567
|
-
console_url: z.string().optional().describe("URL to manage all apps in the grid."),
|
|
2568
|
-
current_visibility: z.string().optional().describe("Current visibility of the drop."),
|
|
2569
|
-
visibility_options: z.array(z.object({
|
|
2570
|
-
value: z.string().describe("Visibility value to pass to grid_visibility."),
|
|
2571
|
-
label: z.string().describe("Human-readable label."),
|
|
2572
|
-
})).optional().describe("Available visibility levels."),
|
|
2573
|
-
needs_grid: z.boolean().optional().describe("True when the user must choose a grid before dropping."),
|
|
2574
|
-
needs_org: z.boolean().optional().describe("Deprecated alias of needs_grid (kept so the org-picker widget keeps working)."),
|
|
2575
|
-
grids: z.array(z.object({
|
|
2576
|
-
slug: z.string().describe("Grid slug to pass as the grid parameter."),
|
|
2577
|
-
name: z.string().describe("Human-readable grid name."),
|
|
2578
|
-
role: z.string().describe("User's role in the grid."),
|
|
2579
|
-
is_active: z.boolean().optional().describe("True if this is the user's currently active grid."),
|
|
2580
|
-
})).optional().describe("The user's grids, when a grid choice is needed."),
|
|
2581
|
-
orgs: z.array(z.object({
|
|
2582
|
-
slug: z.string().describe("Grid slug to pass as the grid parameter."),
|
|
2583
|
-
name: z.string().describe("Human-readable grid name."),
|
|
2584
|
-
role: z.string().describe("User's role in the grid."),
|
|
2585
|
-
is_active: z.boolean().optional().describe("True if this is the user's currently active grid."),
|
|
2586
|
-
})).optional().describe("Deprecated alias of grids (kept so the org-picker widget keeps working)."),
|
|
2587
|
-
needs_sign_in: z.boolean().optional().describe("True when sign-in is needed before dropping."),
|
|
2588
|
-
login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
|
|
2589
|
-
},
|
|
2590
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2591
|
-
...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
|
|
2592
|
-
_meta: {
|
|
2593
|
-
ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
|
|
2594
|
-
"openai/outputTemplate": LIVE_RESULT_URI,
|
|
2595
|
-
},
|
|
2596
|
-
} : {}),
|
|
2597
|
-
},
|
|
2598
|
-
async (input) => {
|
|
2599
|
-
try {
|
|
2600
|
-
// Web edition: reject `path` early — the hosted server cannot read
|
|
2601
|
-
// local files. The schema already omits it, but a model with a
|
|
2602
|
-
// cached tool description might still send one.
|
|
2603
|
-
if (ctx.edition === "web" && input?.path) {
|
|
2604
|
-
return fail(
|
|
2605
|
-
"The hosted server cannot read local files — pass the full document as `html` instead of a `path`.",
|
|
2606
|
-
);
|
|
2607
|
-
}
|
|
2608
|
-
|
|
2609
|
-
// Web edition: sign-in guidance when unauthenticated.
|
|
2610
|
-
if (ctx.edition === "web" && input?.anonymous !== true) {
|
|
2611
|
-
const token = await ctx.getToken();
|
|
2612
|
-
if (!token) {
|
|
2613
|
-
const url = buildLoginUrl(newLoginCode());
|
|
2614
|
-
return okResult({
|
|
2615
|
-
text: `Sign in to publish to your org.\n${url}`,
|
|
2616
|
-
structured: { needs_sign_in: true, login_url: url },
|
|
2617
|
-
});
|
|
2618
|
-
}
|
|
2619
|
-
}
|
|
2620
|
-
|
|
2621
|
-
// Stateless grid disambiguation — both editions, when authenticated.
|
|
2622
|
-
// No dependency on prior-call state so it works even when the client
|
|
2623
|
-
// reconnects on every tool call (ChatGPT Apps SDK behaviour). `grid` is
|
|
2624
|
-
// accepted as an alias for `org` (Gilad's rename); `org` still works.
|
|
2625
|
-
if (input?.anonymous !== true) {
|
|
2626
|
-
const token = await ctx.getToken();
|
|
2627
|
-
if (token) {
|
|
2628
|
-
const suppliedGrid = input?.grid || input?.org;
|
|
2629
|
-
const decision = await resolveGridOrAsk(ctx, {
|
|
2630
|
-
token,
|
|
2631
|
-
suppliedGrid,
|
|
2632
|
-
edition: ctx.edition,
|
|
2633
|
-
});
|
|
2634
|
-
if (decision.picker) {
|
|
2635
|
-
return okResult(decision.picker);
|
|
2636
|
-
}
|
|
2637
|
-
if (decision.single) {
|
|
2638
|
-
if (!decision.single.render_ready) {
|
|
2639
|
-
// Single grid but not set up — tell the agent so it can warn
|
|
2640
|
-
// the user and offer anonymous drop as fallback. (drop-specific:
|
|
2641
|
-
// drop blocks here; plug proceeds with a warning.)
|
|
2642
|
-
const annotated = [{ ...decision.single, render_ready: false }];
|
|
2643
|
-
return okResult({
|
|
2644
|
-
text: `Your only grid "${decision.single.slug}" isn't fully set up yet — pages published there may not load. You can drop anonymously (set anonymous: true) or wait until provisioning completes, then re-run.`,
|
|
2645
|
-
structured: { needs_grid: true, needs_org: true, grid_not_ready: true, org_not_ready: true, grids: annotated, orgs: annotated },
|
|
2646
|
-
});
|
|
2647
|
-
}
|
|
2648
|
-
input = { ...(input || {}), org: decision.single.slug };
|
|
2649
|
-
} else if (decision.grid) {
|
|
2650
|
-
// Supplied grid matched — normalize onto `org` for runDrop.
|
|
2651
|
-
input = { ...(input || {}), org: decision.grid };
|
|
2652
|
-
}
|
|
2653
|
-
}
|
|
2654
|
-
}
|
|
2655
|
-
return okResult(await runDrop(ctx, input || {}));
|
|
2656
|
-
} catch (err) {
|
|
2657
|
-
return fail(err.message);
|
|
2658
|
-
}
|
|
2659
|
-
},
|
|
2660
|
-
);
|
|
2661
|
-
|
|
2662
2315
|
// Claim — both editions.
|
|
2663
2316
|
reg(
|
|
2664
2317
|
"grid_claim",
|
|
@@ -2689,13 +2342,21 @@ export function registerTools(server, ctx) {
|
|
|
2689
2342
|
// CLI-wrapping grid_plug: create and re-plug are one verb, keyed by
|
|
2690
2343
|
// target_entity_id, and work identically on the hosted transport.
|
|
2691
2344
|
const plugInputSchema = {
|
|
2345
|
+
html: z.string().optional().describe(
|
|
2346
|
+
"A single self-contained HTML document to publish as an inspiration — the fast single-file path. " +
|
|
2347
|
+
"Pass the COMPLETE raw HTML inline (CSS+JS inline, images/fonts as data: URIs); a small fragment is " +
|
|
2348
|
+
"wrapped into a full document. Do NOT base64-encode it, and do NOT pass an `@`-prefixed path or a " +
|
|
2349
|
+
"file path here. Mutually exclusive with `path` and `artifact_files`. Materialized as one index.html " +
|
|
2350
|
+
"and published instantly on any edition, anonymously (claimable) or into your grid when signed in.",
|
|
2351
|
+
),
|
|
2352
|
+
filename: z.string().optional().describe("Filename for the single-file `html` path. Defaults to index.html."),
|
|
2692
2353
|
...(ctx.edition === "web"
|
|
2693
2354
|
? {}
|
|
2694
2355
|
: {
|
|
2695
2356
|
path: z.string().optional().describe(
|
|
2696
2357
|
"Local edition: path to the entity folder (or a single file) to upload. A folder is read " +
|
|
2697
2358
|
"recursively, honoring .gitignore/.cloudgridignore (plus .git/node_modules always skipped). " +
|
|
2698
|
-
"Mutually exclusive with artifact_files
|
|
2359
|
+
"Mutually exclusive with `html` and `artifact_files`.",
|
|
2699
2360
|
),
|
|
2700
2361
|
}),
|
|
2701
2362
|
artifact_files: z.array(z.object({
|
|
@@ -2703,8 +2364,10 @@ export function registerTools(server, ctx) {
|
|
|
2703
2364
|
content: z.string().describe("File content. Base64 when encoding is base64, otherwise UTF-8 text."),
|
|
2704
2365
|
encoding: z.enum(["utf8", "base64"]).optional().describe("Content encoding. Default utf8."),
|
|
2705
2366
|
})).optional().describe(
|
|
2706
|
-
"The source inline, one entry per file — for hosted/no-filesystem transports (
|
|
2707
|
-
|
|
2367
|
+
"The source inline, one entry per file — for hosted/no-filesystem transports (a multi-file app). " +
|
|
2368
|
+
"For a single HTML page prefer `html`." +
|
|
2369
|
+
(ctx.edition === "web" ? "" : " Prefer `path` on the local edition.") +
|
|
2370
|
+
" Mutually exclusive with `html` and `path`.",
|
|
2708
2371
|
),
|
|
2709
2372
|
cloudgrid_yaml: z.string().optional().describe(
|
|
2710
2373
|
"Inline cloudgrid.yaml (the entity manifest). Optional — server auto-detection applies when omitted. " +
|
|
@@ -2720,6 +2383,12 @@ export function registerTools(server, ctx) {
|
|
|
2720
2383
|
"never moves grids, but pass its home grid here when it differs from your active grid (the API " +
|
|
2721
2384
|
"checks your membership in the entity's grid). Anonymous → always the Guest grid.",
|
|
2722
2385
|
),
|
|
2386
|
+
slug: z.string().optional().describe(
|
|
2387
|
+
"Alternative RE-PLUG handle: paired with `grid`, resolves to an existing entity (the pickup " +
|
|
2388
|
+
"contract's replug_handle) and updates it in place — for a client that holds only grid+slug, not the " +
|
|
2389
|
+
"raw entity_id. target_entity_id takes precedence. A grid+slug that does NOT resolve to an existing " +
|
|
2390
|
+
"entity is treated as a CREATE (never a false-positive re-plug).",
|
|
2391
|
+
),
|
|
2723
2392
|
hints: z.object({
|
|
2724
2393
|
kind: z.enum(["inspiration", "app", "agent"]).optional().describe("Force the detected kind; omit to let the server auto-detect."),
|
|
2725
2394
|
yaml: z.string().optional().describe("An inline cloudgrid.yaml override used as a detection hint."),
|
|
@@ -2738,17 +2407,22 @@ export function registerTools(server, ctx) {
|
|
|
2738
2407
|
"grid_plug",
|
|
2739
2408
|
{
|
|
2740
2409
|
description:
|
|
2741
|
-
"
|
|
2410
|
+
"Deploy or share a creation on CloudGrid and get a public URL — the ONE create/re-plug verb " +
|
|
2411
|
+
"(POST /api/v2/plug). Use it whenever the user wants to share, publish, send, ship, 'deploy', go " +
|
|
2412
|
+
"live, or get a link to send a friend — for a single HTML page OR a full app. " +
|
|
2413
|
+
"Sources (pass exactly one): `html` — a single self-contained HTML page (the fast path: instant, any " +
|
|
2414
|
+
"edition, anonymous+claimable or into your grid when signed in); " +
|
|
2415
|
+
(ctx.edition === "web"
|
|
2416
|
+
? "or `artifact_files` — a multi-file app inline. "
|
|
2417
|
+
: "`path` — a local folder/file (a multi-file app); or `artifact_files` — inline files. ") +
|
|
2742
2418
|
"No target_entity_id → CREATE a new entity (inspiration/app/agent, auto-detected or hinted); " +
|
|
2743
|
-
"with target_entity_id
|
|
2419
|
+
"with target_entity_id (or grid+slug — the replug_handle, when you hold only those) → RE-PLUG: " +
|
|
2420
|
+
"update the SAME entity in place — same entity_id, same URL, same " +
|
|
2744
2421
|
"deploy history, expiry reset. The returned entity_id + url are the durable re-plug handle; persist " +
|
|
2745
|
-
"them (plus owner_token for anonymous
|
|
2746
|
-
(
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
" Note: in-place re-plug currently supports inspirations (HTML/static drops); to rebuild a deployed " +
|
|
2750
|
-
"app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder." +
|
|
2751
|
-
" If you want to edit an existing drop but no longer have its HTML, call grid_source first to " +
|
|
2422
|
+
"them (plus owner_token for anonymous pages) to update the entity in later sessions. " +
|
|
2423
|
+
"Note: in-place re-plug currently supports inspirations (HTML/static pages); to rebuild a deployed " +
|
|
2424
|
+
"app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder. " +
|
|
2425
|
+
"If you want to edit an existing page but no longer have its HTML, call grid_source first to " +
|
|
2752
2426
|
"retrieve it, then re-plug with target_entity_id.",
|
|
2753
2427
|
inputSchema: plugInputSchema,
|
|
2754
2428
|
outputSchema: {
|
|
@@ -2761,12 +2435,24 @@ export function registerTools(server, ctx) {
|
|
|
2761
2435
|
claim_url: z.string().optional().describe("Anon create only: sign-in link to claim ownership."),
|
|
2762
2436
|
claim_message: z.string().optional().describe("Anon create only: the claim nudge to relay."),
|
|
2763
2437
|
owner_token: z.string().optional().describe("Anonymous drops: the bearer owner token (re-plug + claim). Re-minted on every anonymous edit — persist the newest."),
|
|
2438
|
+
console_url: z.string().optional().describe("Web authed inspiration create: URL to manage all apps in the grid."),
|
|
2439
|
+
current_visibility: z.string().optional().describe("Web authed inspiration create: the visibility set after publish (link)."),
|
|
2440
|
+
visibility_options: z.array(z.object({
|
|
2441
|
+
value: z.string().describe("Visibility value to pass to grid_visibility."),
|
|
2442
|
+
label: z.string().describe("Human-readable label."),
|
|
2443
|
+
})).optional().describe("Web authed inspiration create: available visibility levels."),
|
|
2764
2444
|
},
|
|
2765
2445
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2446
|
+
...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
|
|
2447
|
+
_meta: {
|
|
2448
|
+
ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
|
|
2449
|
+
"openai/outputTemplate": LIVE_RESULT_URI,
|
|
2450
|
+
},
|
|
2451
|
+
} : {}),
|
|
2766
2452
|
},
|
|
2767
2453
|
async (input) => {
|
|
2768
2454
|
try {
|
|
2769
|
-
// Grid-picker
|
|
2455
|
+
// Grid-picker: a signed-in user with >1 grid is
|
|
2770
2456
|
// ASKED which grid to publish to on every CREATE. Only for authed creates
|
|
2771
2457
|
// (no target_entity_id, not anon). Edits NEVER ask — the grid is fixed by
|
|
2772
2458
|
// the entity. Anon proceeds as a Guest-Grid drop. Explicit valid grid
|
|
@@ -2881,10 +2567,14 @@ export function registerTools(server, ctx) {
|
|
|
2881
2567
|
description:
|
|
2882
2568
|
"Retrieve the CURRENT deployed HTML of an inspiration/drop inline as text, so you can edit it and " +
|
|
2883
2569
|
"re-plug the SAME URL when you no longer have its source in context (e.g. the user asks to 'change the " +
|
|
2884
|
-
"color' of a
|
|
2885
|
-
"url (or grid+slug).
|
|
2886
|
-
"
|
|
2887
|
-
"
|
|
2570
|
+
"color' of a page — even in a fresh chat with only its URL). Defaults to this session's last drop; " +
|
|
2571
|
+
"otherwise pass the public url (or grid+slug). Given just a URL with no session, it resolves the " +
|
|
2572
|
+
"entity_id via the pickup contract and also returns the entity's kind, single_html, capabilities " +
|
|
2573
|
+
"(replug/fork), and replug_handle — read those to decide whether to edit in place (single-HTML + " +
|
|
2574
|
+
"capabilities.replug), fall back for a multi-file app/agent (use source_download_url + the local " +
|
|
2575
|
+
"edition/CLI), or offer a fork when it isn't yours. For multi-file or runtime (app/agent) source " +
|
|
2576
|
+
"bundles, use grid_download (signed tarball URLs). Reads the HTML from the API server-side; " +
|
|
2577
|
+
"read-only, creates nothing.",
|
|
2888
2578
|
inputSchema: {
|
|
2889
2579
|
entity_id: z.string().optional().describe("The drop's durable id. Defaults to this session's last drop."),
|
|
2890
2580
|
url: z.string().optional().describe("The public URL of the drop (e.g. https://<grid>.cloudgrid.io/<slug>). Defaults to this session's last drop URL."),
|
|
@@ -2897,6 +2587,19 @@ export function registerTools(server, ctx) {
|
|
|
2897
2587
|
bytes: z.number().describe("Total size of the live document in bytes."),
|
|
2898
2588
|
truncated: z.boolean().describe("True if the body exceeded 1.5MB and was cut — the drop may be multi-file."),
|
|
2899
2589
|
html: z.string().describe("The current deployed HTML (truncated to 1.5MB when oversized)."),
|
|
2590
|
+
kind: z.string().optional().describe("Resolved via the pickup contract: inspiration | app | agent."),
|
|
2591
|
+
single_html: z.boolean().optional().describe("Resolved via the pickup contract: true when this is a single editable HTML document (edit-in-place); false → multi-file."),
|
|
2592
|
+
capabilities: z.object({
|
|
2593
|
+
replug: z.boolean().describe("Whether the caller may re-plug (write) this entity in place."),
|
|
2594
|
+
fork: z.boolean().optional().describe("Whether the caller may fork it."),
|
|
2595
|
+
reason: z.string().optional().describe("Why an action is unavailable, e.g. not_owner."),
|
|
2596
|
+
}).optional().describe("Resolved via the pickup contract: what the caller may do with this entity."),
|
|
2597
|
+
replug_handle: z.object({
|
|
2598
|
+
target_entity_id: z.string().optional().describe("Pass as grid_plug's target_entity_id to re-plug in place."),
|
|
2599
|
+
grid: z.string().optional().describe("The entity's home grid slug."),
|
|
2600
|
+
slug: z.string().optional().describe("The entity's grid-scoped slug."),
|
|
2601
|
+
}).optional().describe("Resolved via the pickup contract: the durable re-plug handle."),
|
|
2602
|
+
source_download_url: z.string().optional().describe("Resolved via the pickup contract: the source-download route (used for the multi-file fallback message)."),
|
|
2900
2603
|
},
|
|
2901
2604
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
2902
2605
|
},
|
|
@@ -3174,7 +2877,7 @@ export function registerTools(server, ctx) {
|
|
|
3174
2877
|
.describe("Short summary of what failed (required). Do not paste the whole conversation here."),
|
|
3175
2878
|
context: z
|
|
3176
2879
|
.object({
|
|
3177
|
-
tool: z.string().optional().describe("The CloudGrid tool that failed, e.g.
|
|
2880
|
+
tool: z.string().optional().describe("The CloudGrid tool that failed, e.g. grid_plug."),
|
|
3178
2881
|
inputs: z.any().optional().describe("The failing inputs (e.g. the HTML/args). Keep it minimal; secrets are scrubbed."),
|
|
3179
2882
|
grid: z.string().optional().describe("The grid/org slug involved, if any."),
|
|
3180
2883
|
original_request: z.string().optional().describe("What the user asked for, in one line."),
|