@cloudgrid-io/mcp 0.21.8 → 0.21.10
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 +2 -1
- package/src/oauth.js +142 -8
- package/src/tools/deploy.js +49 -71
- package/src/tools/register.js +17 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.10",
|
|
4
4
|
"description": "MCP access to CloudGrid — the live runtime environment where the user's apps run WITH the infrastructure they need out of the box: managed MongoDB, Redis (cache/queues/pub-sub), a pgvector store, persistent disk, and built-in AI with no API keys, in any language or stack, no Docker or Kubernetes to manage. Brainstorm, build, plug, and improve apps, games, dashboards, and tools; instant single-page publishes and full runtime-app deploys with a live URL — for developers and non-technical users. Three editions: local stdio (full toolset), hosted web (light, CLI-free), and a public docs-search server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"test:web-identity": "node --test test/web-identity.test.mjs",
|
|
70
70
|
"test:web-auth-integration": "node --test test/web-auth-integration.test.mjs",
|
|
71
71
|
"test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
|
|
72
|
+
"test:oauth-client-persistence": "node --test test/oauth-client-persistence.test.mjs",
|
|
72
73
|
"test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
|
|
73
74
|
"test:internal-refs": "node --test test/no-internal-refs.test.mjs",
|
|
74
75
|
"test:login-app": "node test/login-app.test.mjs",
|
package/src/oauth.js
CHANGED
|
@@ -8,15 +8,19 @@
|
|
|
8
8
|
// then redirect back to the client with an authorization code. /oauth/token
|
|
9
9
|
// exchanges it (PKCE-verified) for the CloudGrid JWT as the access token.
|
|
10
10
|
//
|
|
11
|
-
// No new identity provider.
|
|
11
|
+
// No new identity provider. Client REGISTRATIONS are stateless (a signed,
|
|
12
|
+
// self-describing client_id — durable across restarts and replicas, see #3060);
|
|
13
|
+
// only the short-lived authorize sessions and auth codes are in-memory.
|
|
12
14
|
|
|
13
|
-
import { randomUUID, createHash } from "node:crypto";
|
|
15
|
+
import { randomUUID, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
16
|
import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
|
|
15
17
|
|
|
16
18
|
const CODE_TTL_MS = 5 * 60 * 1000; // authorize sessions + auth codes live 5 minutes
|
|
17
19
|
|
|
18
|
-
// In-memory stores
|
|
19
|
-
|
|
20
|
+
// In-memory stores for the SHORT-LIVED, single-flight halves of the flow. These
|
|
21
|
+
// are 5-minute, retry-on-failure sessions: losing them to a restart just means
|
|
22
|
+
// the user clicks "sign in" again, never that they must re-add the connector.
|
|
23
|
+
// Client REGISTRATIONS are NOT here — they are stateless (see below, #3060).
|
|
20
24
|
const authSessions = new Map(); // sid -> { cgCode, client_id, redirect_uri, state, code_challenge, created }
|
|
21
25
|
const authCodes = new Map(); // code -> { jwt, client_id, redirect_uri, code_challenge, created }
|
|
22
26
|
|
|
@@ -29,6 +33,95 @@ function b64url(buf) {
|
|
|
29
33
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
// --- Stateless client registration (#3060) -----------------------------------
|
|
37
|
+
// A `client_id` is a signed token that CARRIES its own redirect-URI set, not a
|
|
38
|
+
// random handle into a process-local Map. Registration signs the (sorted,
|
|
39
|
+
// deduped) set under a server secret; /oauth/authorize verifies by recomputing
|
|
40
|
+
// the HMAC and checking `redirect_uri` against the signed set. This makes
|
|
41
|
+
// registration durable across restarts, deploys, and ANY replica count with no
|
|
42
|
+
// datastore, and removes the old unbounded-growth `clients` Map entirely.
|
|
43
|
+
//
|
|
44
|
+
// PKCE and redirect validation are untouched — if anything stricter: the
|
|
45
|
+
// redirect set is now cryptographically bound into the client_id, so a tampered
|
|
46
|
+
// client_id fails the HMAC and is rejected.
|
|
47
|
+
//
|
|
48
|
+
// SECRET: MCP_OAUTH_HMAC_SECRET, read from env, sourced from a Kubernetes Secret
|
|
49
|
+
// (NEVER a ConfigMap), never logged. It is a whitespace/comma-separated list:
|
|
50
|
+
// the FIRST entry signs, ALL entries verify.
|
|
51
|
+
//
|
|
52
|
+
// ROTATION (rolling-update safe — order matters). Do NOT simply prepend the new
|
|
53
|
+
// secret: during a rollout, new pods would sign under a key the not-yet-updated
|
|
54
|
+
// old pods cannot verify, so authorize returns transient 400s. Instead:
|
|
55
|
+
// 1. APPEND the new secret (last position = verify-only) and deploy. Every pod
|
|
56
|
+
// now VERIFIES both keys; all pods still SIGN under the old first entry.
|
|
57
|
+
// 2. Once the rollout is complete, PROMOTE the new secret to first position
|
|
58
|
+
// (the signing slot) and deploy. New client_ids are signed under it; the
|
|
59
|
+
// old key still verifies outstanding ones.
|
|
60
|
+
// 3. Later, DROP the old key. client_ids minted under it stop verifying and
|
|
61
|
+
// those users re-add the connector ONCE — the deliberate-rotation cost,
|
|
62
|
+
// versus today's every-deploy breakage.
|
|
63
|
+
// An attacker who obtains the secret can forge a client_id for an arbitrary
|
|
64
|
+
// redirect set — but /oauth/register already mints one for any redirect set
|
|
65
|
+
// unauthenticated, so the secret gates INTEGRITY, not registration; PKCE +
|
|
66
|
+
// CloudGrid sign-in still gate getting a token.
|
|
67
|
+
const CLIENT_ID_PREFIX = "cg1"; // versioned so the scheme can evolve
|
|
68
|
+
|
|
69
|
+
// A client_id CARRIES its redirect set and travels in a query string, so the set
|
|
70
|
+
// must stay bounded or the token grows without limit. Caps chosen well above any
|
|
71
|
+
// real client (ChatGPT/Claude register one or two URIs).
|
|
72
|
+
const MAX_REDIRECT_URIS = 10;
|
|
73
|
+
const MAX_REDIRECT_URIS_BYTES = 4096;
|
|
74
|
+
// A configured signing key shorter than this is weak for an HMAC secret; we warn
|
|
75
|
+
// but still accept it — any configured key beats the ephemeral fallback (#3060).
|
|
76
|
+
const MIN_SECRET_LENGTH = 32;
|
|
77
|
+
|
|
78
|
+
function resolveClientSecrets(env = process.env) {
|
|
79
|
+
const configured = (env.MCP_OAUTH_HMAC_SECRET || "").split(/[\s,]+/).filter(Boolean);
|
|
80
|
+
if (configured.length > 0) return { secrets: configured, ephemeral: false };
|
|
81
|
+
// No configured secret: a per-process ephemeral one. The flow works within a
|
|
82
|
+
// single process, but registrations do NOT survive a restart — i.e. #3060 is
|
|
83
|
+
// unfixed. Production MUST set the secret; the boot log states which mode is
|
|
84
|
+
// active so a deploy can be verified.
|
|
85
|
+
return { secrets: [randomBytes(32).toString("hex")], ephemeral: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Canonical form the signature covers: sorted + deduped, as JSON.
|
|
89
|
+
function canonicalRedirectUris(uris) {
|
|
90
|
+
return JSON.stringify([...new Set(uris.map((u) => String(u)))].sort());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function signClientId(canonical, secret) {
|
|
94
|
+
const payload = b64url(Buffer.from(canonical, "utf8"));
|
|
95
|
+
const signingInput = `${CLIENT_ID_PREFIX}.${payload}`;
|
|
96
|
+
const sig = b64url(createHmac("sha256", secret).update(signingInput).digest());
|
|
97
|
+
return `${signingInput}.${sig}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Verify a client_id against any accepted secret; returns { redirect_uris } or null.
|
|
101
|
+
function verifyClientId(clientId, secrets) {
|
|
102
|
+
const parts = String(clientId).split(".");
|
|
103
|
+
if (parts.length !== 3 || parts[0] !== CLIENT_ID_PREFIX) return null;
|
|
104
|
+
const [, payload, sig] = parts;
|
|
105
|
+
const signingInput = `${CLIENT_ID_PREFIX}.${payload}`;
|
|
106
|
+
const given = Buffer.from(sig);
|
|
107
|
+
let ok = false;
|
|
108
|
+
for (const secret of secrets) {
|
|
109
|
+
const expected = Buffer.from(b64url(createHmac("sha256", secret).update(signingInput).digest()));
|
|
110
|
+
if (expected.length === given.length && timingSafeEqual(expected, given)) {
|
|
111
|
+
ok = true;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!ok) return null;
|
|
116
|
+
try {
|
|
117
|
+
const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
118
|
+
const uris = JSON.parse(json);
|
|
119
|
+
return Array.isArray(uris) ? { redirect_uris: uris } : null;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
32
125
|
function corsOk(res) {
|
|
33
126
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
34
127
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
@@ -81,6 +174,33 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
81
174
|
if (!opts.requireAuth) return;
|
|
82
175
|
const base = publicBase.replace(/\/+$/, "");
|
|
83
176
|
|
|
177
|
+
// Resolve the client-registration signing secret(s) once at mount. Log which
|
|
178
|
+
// mode is active — durable vs. ephemeral — so a production deploy can be
|
|
179
|
+
// verified from the boot log without ever printing the secret itself.
|
|
180
|
+
const clientSecrets = resolveClientSecrets();
|
|
181
|
+
if (clientSecrets.ephemeral) {
|
|
182
|
+
console.warn(
|
|
183
|
+
"[oauth] MCP_OAUTH_HMAC_SECRET is NOT set — OAuth client registrations use a per-process " +
|
|
184
|
+
"ephemeral secret (see #3060). This is NOT just a survives-restart issue: with more than " +
|
|
185
|
+
"one replica, each process mints under its own key, so a client_id registered on one " +
|
|
186
|
+
"replica fails on another — authorize breaks intermittently, per request, with no restart " +
|
|
187
|
+
"involved, which diagnoses as an unrelated bug. Set the secret in production.",
|
|
188
|
+
);
|
|
189
|
+
} else {
|
|
190
|
+
const weak = clientSecrets.secrets.filter((s) => s.length < MIN_SECRET_LENGTH).length;
|
|
191
|
+
if (weak > 0) {
|
|
192
|
+
console.warn(
|
|
193
|
+
`[oauth] MCP_OAUTH_HMAC_SECRET has ${weak} key(s) shorter than ${MIN_SECRET_LENGTH} chars — ` +
|
|
194
|
+
"weak for an HMAC signing key; use a long random value (e.g. `openssl rand -hex 32`). " +
|
|
195
|
+
"Accepted anyway (still better than the ephemeral fallback).",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
console.log(
|
|
199
|
+
`[oauth] client-registration secret configured (${clientSecrets.secrets.length} key(s)); ` +
|
|
200
|
+
"registrations are durable across restarts and replicas.",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
84
204
|
const asMetadata = {
|
|
85
205
|
issuer: base,
|
|
86
206
|
authorization_endpoint: `${base}/oauth/authorize`,
|
|
@@ -122,14 +242,28 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
122
242
|
res.status(400).json({ error: "invalid_client_metadata", error_description: "redirect_uris is required." });
|
|
123
243
|
return;
|
|
124
244
|
}
|
|
125
|
-
|
|
126
|
-
|
|
245
|
+
if (redirectUris.length > MAX_REDIRECT_URIS) {
|
|
246
|
+
res.status(400).json({
|
|
247
|
+
error: "invalid_client_metadata",
|
|
248
|
+
error_description: `At most ${MAX_REDIRECT_URIS} redirect_uris are allowed.`,
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const canonical = canonicalRedirectUris(redirectUris);
|
|
253
|
+
if (Buffer.byteLength(canonical, "utf8") > MAX_REDIRECT_URIS_BYTES) {
|
|
254
|
+
res.status(400).json({
|
|
255
|
+
error: "invalid_client_metadata",
|
|
256
|
+
error_description: `redirect_uris exceed the ${MAX_REDIRECT_URIS_BYTES}-byte limit.`,
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const clientId = signClientId(canonical, clientSecrets.secrets[0]);
|
|
127
261
|
res.status(201).json({
|
|
128
262
|
client_id: clientId,
|
|
129
263
|
token_endpoint_auth_method: "none",
|
|
130
264
|
grant_types: ["authorization_code"],
|
|
131
265
|
response_types: ["code"],
|
|
132
|
-
redirect_uris:
|
|
266
|
+
redirect_uris: JSON.parse(canonical),
|
|
133
267
|
});
|
|
134
268
|
});
|
|
135
269
|
|
|
@@ -137,7 +271,7 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
137
271
|
app.get("/oauth/authorize", (req, res) => {
|
|
138
272
|
sweep(authSessions);
|
|
139
273
|
const { client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type } = req.query;
|
|
140
|
-
const client =
|
|
274
|
+
const client = verifyClientId(client_id, clientSecrets.secrets);
|
|
141
275
|
if (!client || !client.redirect_uris.includes(String(redirect_uri))) {
|
|
142
276
|
res.status(400).send("Unknown client or redirect_uri. Re-add the connector and try again.");
|
|
143
277
|
return;
|
package/src/tools/deploy.js
CHANGED
|
@@ -1480,22 +1480,16 @@ function plugErrorMessage(status, code, msg, ctxFlags = {}) {
|
|
|
1480
1480
|
return guidance ? `${base} — ${guidance}` : base;
|
|
1481
1481
|
}
|
|
1482
1482
|
|
|
1483
|
-
// ──
|
|
1484
|
-
//
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1483
|
+
// ── Corrupt-archive FETCHSOURCE handoff (defense in depth) ───────────────────
|
|
1484
|
+
// Historically the hosted (web) server materialized uploaded multipart parts
|
|
1485
|
+
// into a source tarball SERVER-SIDE and, for a multi-file runtime app re-plugged
|
|
1486
|
+
// inline, could emit a non-gzip stream that died at the build container's
|
|
1487
1487
|
// FETCHSOURCE with "gzip: stdin: not in gzip format / tar: Child returned status
|
|
1488
|
-
// 1" — raw, unactionable output
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
// WHAT WOULD LET THIS BE REMOVED: the API-side fix to tarball materialization for
|
|
1494
|
-
// this shape (tracked on #311). Until then, two defenses live here:
|
|
1495
|
-
// 1. detect the shape BEFORE upload (the gate in runPlug) and hand over the
|
|
1496
|
-
// CLI commands with the real entity handle — never fail at the build.
|
|
1497
|
-
// 2. if a corrupt-archive build failure still slips through, rewrite the raw
|
|
1498
|
-
// tar/gzip log to the same actionable handoff (archiveCorruptionSignature).
|
|
1488
|
+
// 1" — raw, unactionable output. The server-side defect (cloudgrid#2977) is now
|
|
1489
|
+
// fixed and verified live, and the pre-upload gate that refused that shape has
|
|
1490
|
+
// been lifted (#315). This handoff stays as defense in depth: if a corrupt-archive
|
|
1491
|
+
// build failure ever recurs, rewrite the raw tar/gzip log to an actionable CLI
|
|
1492
|
+
// handoff (via archiveCorruptionSignature) instead of surfacing it verbatim.
|
|
1499
1493
|
|
|
1500
1494
|
// The "continue this exact entity from a terminal" CLI commands, with the real
|
|
1501
1495
|
// handle substituted. Prefers grid/slug (what `pull` takes, human-readable);
|
|
@@ -1512,7 +1506,7 @@ export function cliContinueHandoff({ entityId, grid, slug } = {}) {
|
|
|
1512
1506
|
);
|
|
1513
1507
|
}
|
|
1514
1508
|
|
|
1515
|
-
// True when a build failure is the
|
|
1509
|
+
// True when a build failure is the corrupt-archive signature (a FETCHSOURCE
|
|
1516
1510
|
// gunzip/tar failure), as opposed to a genuine app build error the developer can
|
|
1517
1511
|
// act on (a missing dependency, a syntax error) — those keep their real log tail.
|
|
1518
1512
|
export function archiveCorruptionSignature(text) {
|
|
@@ -1750,40 +1744,6 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1750
1744
|
|
|
1751
1745
|
const isEdit = typeof targetEntityId === "string" && targetEntityId.length > 0;
|
|
1752
1746
|
|
|
1753
|
-
// ── WORKAROUND(#311): pre-upload gate for inline multi-file runtime RE-PLUG ──
|
|
1754
|
-
// WHAT IS BROKEN: the hosted server's tarball materialization corrupts the
|
|
1755
|
-
// source archive for a multi-file runtime app re-plugged INLINE — it emits a
|
|
1756
|
-
// non-gzip stream that dies at the build container's FETCHSOURCE with
|
|
1757
|
-
// "gzip: stdin: not in gzip format / tar: Child returned status 1". This client
|
|
1758
|
-
// uploads byte-exact octet-stream parts (proven in test), so the defect is
|
|
1759
|
-
// SERVER-SIDE, not here. It stranded a real user (natalys-grid) twice.
|
|
1760
|
-
// WHAT WOULD LET THIS BE REMOVED: the API-side fix to tarball materialization
|
|
1761
|
-
// for this shape (tracked on #311). Once hosted rebuilds a multi-file inline
|
|
1762
|
-
// re-plug reliably, delete this gate — the inline path would just work.
|
|
1763
|
-
// Until then, detect the shape BEFORE uploading and hand over the CLI, which
|
|
1764
|
-
// uploads from disk and updates the SAME entity. NARROW by design so it never
|
|
1765
|
-
// touches the cases that work today: hosted (web) only, RE-PLUG only (a real
|
|
1766
|
-
// entity id in hand to substitute), MULTIPLE files, and a ROOT cloudgrid.yaml
|
|
1767
|
-
// that DECLARES services (i.e. a runtime app, not a static bundle). A single
|
|
1768
|
-
// HTML page, or a few small text files with no service manifest, are untouched
|
|
1769
|
-
// and still plug inline. Multi-file runtime CREATES are also untouched (issue
|
|
1770
|
-
// #48 aligned that bundle) — only the re-plug shape that failed twice is gated.
|
|
1771
|
-
if (ctx.edition === "web" && isEdit && hasArtifacts && artifacts.length > 1) {
|
|
1772
|
-
const manifest = detectSourceManifest(input);
|
|
1773
|
-
if (manifest && manifest.services.length > 0) {
|
|
1774
|
-
const slugForMsg = pickupData?.slug || (typeof slug === "string" && slug ? slug : null);
|
|
1775
|
-
const gridForMsg = pickupData?.grid || (typeof grid === "string" && grid ? grid : null);
|
|
1776
|
-
throw new Error(
|
|
1777
|
-
`This is a multi-file runtime app (${artifacts.length} files + a cloudgrid.yaml declaring ` +
|
|
1778
|
-
`services: ${manifest.services.join(", ")}) being re-plugged inline on the hosted server. ` +
|
|
1779
|
-
"The hosted server can't reliably rebuild a multi-file app from an inline upload — the source " +
|
|
1780
|
-
"archive arrives corrupt at build (#311), which is why the identical source plugs fine from the " +
|
|
1781
|
-
"CLI. " +
|
|
1782
|
-
cliContinueHandoff({ entityId: targetEntityId, grid: gridForMsg, slug: slugForMsg }),
|
|
1783
|
-
);
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
1747
|
// An inspiration edit content-versions the FIRST uploaded artifact — when a
|
|
1788
1748
|
// multi-file folder rides a re-plug, put the primary entry first so the edit
|
|
1789
1749
|
// swaps the right file (index.html > any .html > everything else).
|
|
@@ -2127,18 +2087,17 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2127
2087
|
data.status = "live";
|
|
2128
2088
|
data.poll_url = undefined;
|
|
2129
2089
|
} else if (verdict.status === "failed") {
|
|
2130
|
-
//
|
|
2131
|
-
// as raw tar output — never surface it verbatim.
|
|
2132
|
-
//
|
|
2133
|
-
//
|
|
2134
|
-
//
|
|
2135
|
-
//
|
|
2136
|
-
//
|
|
2137
|
-
// keep their real log tail — those the developer CAN act on.
|
|
2090
|
+
// Defense in depth: a corrupt-archive FETCHSOURCE failure is unactionable
|
|
2091
|
+
// as raw tar output — never surface it verbatim. The server-side defect
|
|
2092
|
+
// that produced it (cloudgrid#2977) is fixed and the pre-upload gate has
|
|
2093
|
+
// been lifted (#315), so no corrupt-archive verdict is expected here; if the
|
|
2094
|
+
// class ever recurs, name the CLI path with the real handle instead of the
|
|
2095
|
+
// raw log. Genuine app build errors keep their real log tail — those the
|
|
2096
|
+
// developer CAN act on.
|
|
2138
2097
|
if (archiveCorruptionSignature(`${verdict.error || ""}\n${verdict.logTail || ""}`)) {
|
|
2139
2098
|
throw new Error(
|
|
2140
2099
|
"The uploaded source could not be read by the build (its archive was corrupt) — the URL is NOT " +
|
|
2141
|
-
"live.
|
|
2100
|
+
"live. " +
|
|
2142
2101
|
cliContinueHandoff({ entityId: data.entity_id, grid: data.grid, slug: data.slug }),
|
|
2143
2102
|
);
|
|
2144
2103
|
}
|
|
@@ -2271,16 +2230,32 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2271
2230
|
lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
|
|
2272
2231
|
}
|
|
2273
2232
|
|
|
2233
|
+
// Point the user at their grid (the console) after a deploy that is LIVE — new
|
|
2234
|
+
// or re-plug (#321). The app URL says where the app lives; the console line says
|
|
2235
|
+
// where the grid lives, so the common follow-up interaction (an edit) does not
|
|
2236
|
+
// end with a bare URL and no way back. The console root self-routes per account
|
|
2237
|
+
// state (sign-in wall → their grid → onboarding); there is no per-grid page to
|
|
2238
|
+
// link, so keep the root — a `/grids/<slug>` path 404s today.
|
|
2239
|
+
//
|
|
2240
|
+
// NOT on the building branch: the line says "view it live", and a still-building
|
|
2241
|
+
// app is not live — emitting it two lines under "Do NOT tell the user it is live"
|
|
2242
|
+
// would hand a model the word "live" next to a pending app. For an async runtime
|
|
2243
|
+
// build the console line arrives when liveness is confirmed, in runCheckDeploy's
|
|
2244
|
+
// success branch.
|
|
2245
|
+
if (data.entity_id && !isBuilding) {
|
|
2246
|
+
structured.console_url = CONSOLE_URL;
|
|
2247
|
+
lines.push(`You can view it live in your grid along with all the apps you build here: ${CONSOLE_URL}`);
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2274
2250
|
// Visibility is the user's choice — never set silently. On a NEW deploy,
|
|
2275
2251
|
// surface the current visibility + the full option set and have the agent ASK
|
|
2276
2252
|
// the user, then apply their answer via grid_visibility. On an edit, leave the
|
|
2277
|
-
// entity's existing visibility untouched (don't re-ask on every re-plug)
|
|
2253
|
+
// entity's existing visibility untouched (don't re-ask on every re-plug) — this
|
|
2254
|
+
// stays `!isEdit` deliberately, decoupled from the console line above.
|
|
2278
2255
|
if (!isEdit && data.entity_id) {
|
|
2279
2256
|
const current = typeof data.visibility === "string" ? data.visibility : null;
|
|
2280
|
-
structured.console_url = CONSOLE_URL;
|
|
2281
2257
|
if (current) structured.current_visibility = current;
|
|
2282
2258
|
structured.visibility_options = VISIBILITY_OPTIONS.map((v) => ({ value: v, label: VISIBILITY_LABELS[v] }));
|
|
2283
|
-
lines.push(`Manage all your apps in your grid: ${CONSOLE_URL}`);
|
|
2284
2259
|
lines.push(
|
|
2285
2260
|
`Now ASK the user who should be able to open this${current ? ` (currently ${VISIBILITY_LABELS[current] ?? current})` : ""}, then set their choice with grid_visibility — do not decide it for them. Options: ${
|
|
2286
2261
|
VISIBILITY_OPTIONS
|
|
@@ -2758,8 +2733,12 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
|
|
|
2758
2733
|
const url = ctx.state.lastDrop?.url;
|
|
2759
2734
|
if (verdict.status === "success") {
|
|
2760
2735
|
return {
|
|
2761
|
-
|
|
2762
|
-
|
|
2736
|
+
// The "now live" moment for an async runtime build lands here, not in
|
|
2737
|
+
// runPlug (which returned "building"). Point the user at their grid too
|
|
2738
|
+
// (#321) — this is where liveness is confirmed, so "view it live" is true.
|
|
2739
|
+
text: `Live${url ? `: ${url}` : ""} — the build finished. Give the user the URL.\n` +
|
|
2740
|
+
`You can view it live in your grid along with all the apps you build here: ${CONSOLE_URL}`,
|
|
2741
|
+
structured: { status: "success", live: true, ...(url ? { url } : {}), console_url: CONSOLE_URL },
|
|
2763
2742
|
};
|
|
2764
2743
|
}
|
|
2765
2744
|
if (verdict.status === "failed") {
|
|
@@ -2769,16 +2748,15 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
|
|
|
2769
2748
|
// the source zip (grid_get_app_source → source_download_url) or the
|
|
2770
2749
|
// pull command that downloads + links the folder so the SAME entity
|
|
2771
2750
|
// continues locally.
|
|
2772
|
-
//
|
|
2773
|
-
// tar output — do NOT echo it.
|
|
2774
|
-
//
|
|
2775
|
-
//
|
|
2776
|
-
//
|
|
2751
|
+
// Defense in depth: a corrupt-archive FETCHSOURCE failure is unactionable raw
|
|
2752
|
+
// tar output — do NOT echo it. The server-side defect that produced it
|
|
2753
|
+
// (cloudgrid#2977) is fixed and the pre-upload gate has been lifted (#315);
|
|
2754
|
+
// if the class ever recurs, name the CLI continue-path with the real handle
|
|
2755
|
+
// instead of the raw log.
|
|
2777
2756
|
if (archiveCorruptionSignature(`${verdict.error || ""}\n${verdict.logTail || ""}`)) {
|
|
2778
2757
|
return {
|
|
2779
2758
|
text: "The uploaded source could not be read by the build (its archive was corrupt) — the URL is " +
|
|
2780
|
-
"not live.
|
|
2781
|
-
"project files are NOT lost — they are saved on the entity. " +
|
|
2759
|
+
"not live. The project files are NOT lost — they are saved on the entity. " +
|
|
2782
2760
|
cliContinueHandoff({ entityId, grid: gridSlug, slug: ctx.state.lastDrop?.slug }),
|
|
2783
2761
|
structured: {
|
|
2784
2762
|
status: "failed",
|
package/src/tools/register.js
CHANGED
|
@@ -845,6 +845,7 @@ export function registerTools(server, ctx) {
|
|
|
845
845
|
status: z.string().describe("The build status: success | failed | building | queued | unknown."),
|
|
846
846
|
live: z.boolean().describe("True only when the build finished successfully and the URL serves."),
|
|
847
847
|
url: z.string().optional().describe("The live URL, when known."),
|
|
848
|
+
console_url: z.string().optional().describe("On success: URL to view the app live in the grid alongside all the user's apps."),
|
|
848
849
|
error: z.string().optional().describe("User-language failure reason, when the build failed."),
|
|
849
850
|
build_log_tail: z.string().optional().describe("Sanitized tail of the Cloud Build log (the real error), when the build failed."),
|
|
850
851
|
suggested_fix: z.string().optional().describe("A suggested fix for the failure, when the platform provides one."),
|
|
@@ -943,7 +944,7 @@ export function registerTools(server, ctx) {
|
|
|
943
944
|
.object({
|
|
944
945
|
active_grid: z.string().nullable().describe("The user's active grid/org slug, or null."),
|
|
945
946
|
signed_in: z.boolean().describe("Whether the current session is signed in."),
|
|
946
|
-
email: z.string().optional().describe("The signed-in user's email,
|
|
947
|
+
email: z.string().optional().describe("The signed-in user's email — the CloudGrid account this session is authenticated as. Present whenever signed_in is true and the session token carries an email claim (the normal case). Absent only if signed_in is false, or on the rare token that lacks the claim; the identity line in this tool's text content states which case applies. Use this to answer 'which account/user am I?'."),
|
|
947
948
|
session_expired: z.boolean().optional().describe("True when credentials exist but the JWT has expired. Run grid_login to sign in again."),
|
|
948
949
|
identity_changed: z.boolean().optional().describe("True when the session's identity changed via a different transport Bearer. Session state was reset."),
|
|
949
950
|
update_available: z
|
|
@@ -1007,8 +1008,22 @@ export function registerTools(server, ctx) {
|
|
|
1007
1008
|
const wfLines = workflows.length
|
|
1008
1009
|
? workflows.map((w) => ` - ${w.name}: ${w.when || w.summary}`).join("\n")
|
|
1009
1010
|
: " (none available)";
|
|
1011
|
+
// Identity banner — first line of the text channel, because the account
|
|
1012
|
+
// this session is signed in as is only otherwise carried in
|
|
1013
|
+
// structuredContent.context, which many clients/models never surface. A
|
|
1014
|
+
// hosted assistant asked "which account am I?" was concluding no tool
|
|
1015
|
+
// exposes it while the answer sat in structured output it had already
|
|
1016
|
+
// received (#317). Stating it in the text the model actually reads makes
|
|
1017
|
+
// "who am I" answerable without the CLI (whose separate session can point
|
|
1018
|
+
// at a different account entirely).
|
|
1019
|
+
const gridSuffix = activeGrid ? ` (active grid: ${activeGrid})` : "";
|
|
1020
|
+
const identityLine = signedIn
|
|
1021
|
+
? signedInEmail
|
|
1022
|
+
? `You are signed in to CloudGrid as ${signedInEmail}${gridSuffix}. If the user asks which CloudGrid account, user, or email they are connected to, that is the answer.`
|
|
1023
|
+
: `You are signed in to CloudGrid${gridSuffix}, but this session's token carries no email claim, so the account address is not available here. Do not guess it from grid memberships or the CLI (its session may be a different account).`
|
|
1024
|
+
: `You are not signed in to CloudGrid. Run grid_login to sign in.`;
|
|
1010
1025
|
let text =
|
|
1011
|
-
`${PLAYBOOK}\n\nAvailable workflows:\n${wfLines}\n\n` +
|
|
1026
|
+
`${identityLine}\n\n${PLAYBOOK}\n\nAvailable workflows:\n${wfLines}\n\n` +
|
|
1012
1027
|
`Next: match the intent to a workflow and call grid_get_template({kind:"workflow", name}).` +
|
|
1013
1028
|
(stale ? `\n\n${stale}` : "");
|
|
1014
1029
|
if (sessionExpired) {
|