@cloudgrid-io/mcp 0.21.9 → 0.21.11
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 +4 -1
- package/src/oauth.js +211 -15
- package/src/tools/deploy.js +69 -12
- package/src/tools/register.js +14 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.11",
|
|
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": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"test:plug-output-schema": "node test/plug-output-schema.test.mjs",
|
|
32
32
|
"test:zip-deploy": "node test/zip-deploy.test.mjs",
|
|
33
33
|
"test:create-gates": "node test/create-gates.test.mjs",
|
|
34
|
+
"test:plug-create-grid-choice": "node test/plug-create-grid-choice.test.mjs",
|
|
34
35
|
"test:grid-provisioning": "node test/grid-provisioning.test.mjs",
|
|
35
36
|
"test:create-project-args": "node test/create-project-args.test.mjs",
|
|
36
37
|
"test:mcpb-boot": "node test/mcpb-boot.test.mjs",
|
|
@@ -69,6 +70,8 @@
|
|
|
69
70
|
"test:web-identity": "node --test test/web-identity.test.mjs",
|
|
70
71
|
"test:web-auth-integration": "node --test test/web-auth-integration.test.mjs",
|
|
71
72
|
"test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
|
|
73
|
+
"test:oauth-client-persistence": "node --test test/oauth-client-persistence.test.mjs",
|
|
74
|
+
"test:oauth-token-exchange": "node --test test/oauth-token-exchange.test.mjs",
|
|
72
75
|
"test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
|
|
73
76
|
"test:internal-refs": "node --test test/no-internal-refs.test.mjs",
|
|
74
77
|
"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`,
|
|
@@ -108,11 +228,18 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
108
228
|
res.json({ resource: `${base}/mcp`, authorization_servers: [base], scopes_supported: ["cloudgrid"] });
|
|
109
229
|
});
|
|
110
230
|
|
|
111
|
-
// RFC 8414.
|
|
112
|
-
|
|
231
|
+
// RFC 8414. Both the root form and the path-inserted form: the MCP
|
|
232
|
+
// authorization spec has clients derive the metadata URL from the RESOURCE
|
|
233
|
+
// url (…/mcp), so a client that tries `/.well-known/oauth-authorization-server/mcp`
|
|
234
|
+
// first must not get a 404 HTML page. The protected-resource document above
|
|
235
|
+
// already served both forms; this one served only the root, and the asymmetry
|
|
236
|
+
// was an oversight rather than a decision.
|
|
237
|
+
const serveAsMetadata = (_req, res) => {
|
|
113
238
|
corsOk(res);
|
|
114
239
|
res.json(asMetadata);
|
|
115
|
-
}
|
|
240
|
+
};
|
|
241
|
+
app.get("/.well-known/oauth-authorization-server", serveAsMetadata);
|
|
242
|
+
app.get("/.well-known/oauth-authorization-server/mcp", serveAsMetadata);
|
|
116
243
|
|
|
117
244
|
// RFC 7591 — dynamic client registration. Public clients, PKCE-only.
|
|
118
245
|
app.post("/oauth/register", (req, res) => {
|
|
@@ -122,14 +249,28 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
122
249
|
res.status(400).json({ error: "invalid_client_metadata", error_description: "redirect_uris is required." });
|
|
123
250
|
return;
|
|
124
251
|
}
|
|
125
|
-
|
|
126
|
-
|
|
252
|
+
if (redirectUris.length > MAX_REDIRECT_URIS) {
|
|
253
|
+
res.status(400).json({
|
|
254
|
+
error: "invalid_client_metadata",
|
|
255
|
+
error_description: `At most ${MAX_REDIRECT_URIS} redirect_uris are allowed.`,
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const canonical = canonicalRedirectUris(redirectUris);
|
|
260
|
+
if (Buffer.byteLength(canonical, "utf8") > MAX_REDIRECT_URIS_BYTES) {
|
|
261
|
+
res.status(400).json({
|
|
262
|
+
error: "invalid_client_metadata",
|
|
263
|
+
error_description: `redirect_uris exceed the ${MAX_REDIRECT_URIS_BYTES}-byte limit.`,
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const clientId = signClientId(canonical, clientSecrets.secrets[0]);
|
|
127
268
|
res.status(201).json({
|
|
128
269
|
client_id: clientId,
|
|
129
270
|
token_endpoint_auth_method: "none",
|
|
130
271
|
grant_types: ["authorization_code"],
|
|
131
272
|
response_types: ["code"],
|
|
132
|
-
redirect_uris:
|
|
273
|
+
redirect_uris: JSON.parse(canonical),
|
|
133
274
|
});
|
|
134
275
|
});
|
|
135
276
|
|
|
@@ -137,7 +278,7 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
137
278
|
app.get("/oauth/authorize", (req, res) => {
|
|
138
279
|
sweep(authSessions);
|
|
139
280
|
const { client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type } = req.query;
|
|
140
|
-
const client =
|
|
281
|
+
const client = verifyClientId(client_id, clientSecrets.secrets);
|
|
141
282
|
if (!client || !client.redirect_uris.includes(String(redirect_uri))) {
|
|
142
283
|
res.status(400).send("Unknown client or redirect_uri. Re-add the connector and try again.");
|
|
143
284
|
return;
|
|
@@ -185,6 +326,9 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
185
326
|
created: Date.now(),
|
|
186
327
|
});
|
|
187
328
|
authSessions.delete(String(req.query.sid));
|
|
329
|
+
// The sign-in half succeeded. Without this line a connector failure gives
|
|
330
|
+
// no way to tell whether the user ever got through sign-in at all.
|
|
331
|
+
console.error("[oauth] authorization code issued after sign-in; awaiting token exchange");
|
|
188
332
|
const sep = sess.redirect_uri.includes("?") ? "&" : "?";
|
|
189
333
|
const redirect = `${sess.redirect_uri}${sep}code=${encodeURIComponent(code)}${sess.state ? `&state=${encodeURIComponent(sess.state)}` : ""}`;
|
|
190
334
|
res.json({ status: "ready", redirect });
|
|
@@ -198,22 +342,74 @@ export function mountOAuth(app, publicBase, opts = {}) {
|
|
|
198
342
|
});
|
|
199
343
|
|
|
200
344
|
// Token endpoint — PKCE-verified exchange; the CloudGrid JWT is the access token.
|
|
345
|
+
//
|
|
346
|
+
// WHY THIS BRANCHES INSTEAD OF ONE COMBINED `if`
|
|
347
|
+
//
|
|
348
|
+
// Reported 2026-08-30: ChatGPT web fails with "Something went wrong with
|
|
349
|
+
// setting up the connection" AFTER the user signs in, which puts the failure
|
|
350
|
+
// in this handler. It could not be diagnosed from either side, because five
|
|
351
|
+
// unrelated causes all produced a byte-identical `{"error":"invalid_grant"}`:
|
|
352
|
+
//
|
|
353
|
+
// unknown/expired code · code already exchanged · client_id absent
|
|
354
|
+
// · client_id mismatched · redirect_uri mismatched (a trailing slash does it)
|
|
355
|
+
//
|
|
356
|
+
// All five were reproduced against this handler (test/oauth-token-exchange).
|
|
357
|
+
// Nothing was logged, so the server could not say which happened either — the
|
|
358
|
+
// failure was invisible from both ends at once. That is the actual defect
|
|
359
|
+
// being fixed here: not the rejection, which is correct in every one of those
|
|
360
|
+
// cases, but that a rejection carried no information.
|
|
361
|
+
//
|
|
362
|
+
// The reason is logged server-side and returned as `error_description`. The
|
|
363
|
+
// caller already holds the code, the client_id and the redirect_uri it sent,
|
|
364
|
+
// so naming which of ITS OWN values did not match tells it nothing it did not
|
|
365
|
+
// already know. The token, the verifier and the code are never logged.
|
|
366
|
+
//
|
|
367
|
+
// THE TRADE, NAMED (so nobody extends this pattern by accident): branching
|
|
368
|
+
// these messages does hand a holder of an EXFILTRATED code a small oracle —
|
|
369
|
+
// they can confirm the code is live and probe client_id / redirect_uri
|
|
370
|
+
// without spending a PKCE guess. Accepted here because the code is
|
|
371
|
+
// single-use, dies in 5 minutes, and PKCE still gates the token, so the
|
|
372
|
+
// oracle reveals only values the attacker would have to have already; and
|
|
373
|
+
// RFC 6749 §5.2 explicitly provides error_description for this purpose. That
|
|
374
|
+
// reasoning is specific to this endpoint. Do NOT copy the pattern somewhere
|
|
375
|
+
// a failed attempt is cheap to repeat or the compared value is a secret.
|
|
376
|
+
const denyToken = (res, error, reason, detail = "") => {
|
|
377
|
+
console.error(`[oauth] token exchange refused: ${reason}${detail ? ` ${detail}` : ""}`);
|
|
378
|
+
res.status(400).json({ error, error_description: reason });
|
|
379
|
+
};
|
|
380
|
+
|
|
201
381
|
app.post("/oauth/token", (req, res) => {
|
|
202
382
|
corsOk(res);
|
|
203
383
|
sweep(authCodes);
|
|
204
384
|
const { grant_type, code, code_verifier, redirect_uri, client_id } = req.body ?? {};
|
|
205
385
|
if (grant_type !== "authorization_code") {
|
|
206
|
-
res
|
|
386
|
+
denyToken(res, "unsupported_grant_type", "grant_type must be authorization_code", `(got ${JSON.stringify(grant_type ?? null)})`);
|
|
207
387
|
return;
|
|
208
388
|
}
|
|
209
389
|
const rec = authCodes.get(String(code));
|
|
210
|
-
if (!rec
|
|
211
|
-
|
|
390
|
+
if (!rec) {
|
|
391
|
+
// Also the retry case: codes are single-use, so a second exchange of a
|
|
392
|
+
// code that already succeeded lands here.
|
|
393
|
+
denyToken(res, "invalid_grant", "authorization code is unknown, expired or already exchanged");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (rec.client_id !== String(client_id)) {
|
|
397
|
+
denyToken(
|
|
398
|
+
res,
|
|
399
|
+
"invalid_grant",
|
|
400
|
+
client_id === undefined ? "client_id is required at the token endpoint" : "client_id does not match the one the code was issued to",
|
|
401
|
+
);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (rec.redirect_uri !== String(redirect_uri)) {
|
|
405
|
+
// The exact strings, because the difference is usually invisible —
|
|
406
|
+
// a trailing slash, a case change, an added query parameter.
|
|
407
|
+
denyToken(res, "invalid_grant", "redirect_uri does not match the authorize request", `(sent ${JSON.stringify(redirect_uri ?? null)}, expected ${JSON.stringify(rec.redirect_uri)})`);
|
|
212
408
|
return;
|
|
213
409
|
}
|
|
214
410
|
const challenge = b64url(createHash("sha256").update(String(code_verifier ?? "")).digest());
|
|
215
411
|
if (challenge !== rec.code_challenge) {
|
|
216
|
-
res
|
|
412
|
+
denyToken(res, "invalid_grant", code_verifier === undefined ? "code_verifier is required (PKCE)" : "PKCE verification failed");
|
|
217
413
|
return;
|
|
218
414
|
}
|
|
219
415
|
authCodes.delete(String(code)); // single use
|
package/src/tools/deploy.js
CHANGED
|
@@ -104,7 +104,15 @@ export async function fetchUserOrgs(token) {
|
|
|
104
104
|
// - >1 grid and none supplied → { picker } (a ready-to-return result)
|
|
105
105
|
// - exactly one grid → { single: annotatedOrg } — the caller
|
|
106
106
|
// decides how to treat a not-ready single grid (drop blocks; plug warns)
|
|
107
|
-
// - no orgs / listing failed → {
|
|
107
|
+
// - no orgs / listing failed → { picker } needs_grid_create (asks)
|
|
108
|
+
// Every return shape either ASKS (a picker) or names an explicit grid — none is a
|
|
109
|
+
// silent proceed. That is load-bearing: register.js returns the picker or sets
|
|
110
|
+
// input.grid from `grid`/`single` before runPlug, so the create path never
|
|
111
|
+
// reaches runPlug's headerless fallback (#327). Do NOT reintroduce a
|
|
112
|
+
// `{ proceed: true }` "fall through" shape here — a proceed with NO grid slips
|
|
113
|
+
// past register.js without setting input.grid, into an ungated runPlug where
|
|
114
|
+
// orgSlug is null and the SERVER picks the grid silently: exactly the wrong-grid
|
|
115
|
+
// create this PR removed. A listing failure must ASK, never fall through.
|
|
108
116
|
// User-facing text says "grid" (Gilad's org→grid rename); the structured payload
|
|
109
117
|
// carries `needs_grid` AND the `needs_org`/`orgs`/`org`-slug fields the existing
|
|
110
118
|
// org-picker web widget reads, so the web card keeps working. Stateless — no
|
|
@@ -1838,13 +1846,42 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1838
1846
|
// through — never a silent redirect.
|
|
1839
1847
|
// 4. the target entity itself — a pickup round-trip, only if 1–3 all
|
|
1840
1848
|
// came up empty. Reuse a pickup already done above to resolve the id.
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1849
|
+
if (isEdit) {
|
|
1850
|
+
// EDIT: the destination is NOT ambiguous — the entity already lives in
|
|
1851
|
+
// exactly one grid, so derive it (never ask). explicit `grid` → active
|
|
1852
|
+
// grid → URL host → entity lookup (#296/#301/#316). This chain must stay
|
|
1853
|
+
// intact: re-asking on every re-plug is noise and would undo those fixes.
|
|
1854
|
+
orgSlug = grid || (await ctx.getActiveGrid());
|
|
1855
|
+
if (!orgSlug) orgSlug = gridFromCloudgridUrl(targetUrl);
|
|
1856
|
+
if (!orgSlug) {
|
|
1857
|
+
if (!pickupData && targetEntityId) {
|
|
1858
|
+
pickupData = await resolveEntityViaPickup(ctx, { target: targetEntityId });
|
|
1859
|
+
}
|
|
1860
|
+
if (pickupData?.grid) orgSlug = pickupData.grid;
|
|
1846
1861
|
}
|
|
1847
|
-
|
|
1862
|
+
} else {
|
|
1863
|
+
// CREATE (#327): the destination grid is the USER'S CHOICE and must be
|
|
1864
|
+
// EXPLICIT — never silently reuse the persisted active grid. A stale
|
|
1865
|
+
// active grid (weeks old, not surfaced to the user) silently lands a NEW
|
|
1866
|
+
// entity in the wrong grid, and the grid decides the URL, who can open it
|
|
1867
|
+
// (grid-scoped visibility), the datastore tier it inherits, and the
|
|
1868
|
+
// namespace it runs in.
|
|
1869
|
+
//
|
|
1870
|
+
// What makes this safe is the grid-picker gate — resolveGridOrAsk (defined
|
|
1871
|
+
// above; called from register.js): it either ASKS (returns a picker) or
|
|
1872
|
+
// sets input.grid to an explicit slug (the single grid, or the user's
|
|
1873
|
+
// choice) BEFORE runPlug runs. So on the real create path `grid` is always
|
|
1874
|
+
// populated here.
|
|
1875
|
+
//
|
|
1876
|
+
// `null` is NOT a backstop — it is the DEFECT value. A headerless authed
|
|
1877
|
+
// create does not refuse; it delegates the choice to the server, which
|
|
1878
|
+
// resolves a default and picks a grid silently (the hosted half of this
|
|
1879
|
+
// bug). The only reason a null is ever safe is that the gate above never
|
|
1880
|
+
// lets an unresolved authed create reach here. Refusing on null (returning
|
|
1881
|
+
// needs_grid instead of proceeding) is the stronger fix, tracked as a #327
|
|
1882
|
+
// follow-up — do NOT rely on `orgSlug = grid || null` alone to prevent a
|
|
1883
|
+
// wrong-grid create.
|
|
1884
|
+
orgSlug = grid || null;
|
|
1848
1885
|
}
|
|
1849
1886
|
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1850
1887
|
if (orgSlug) {
|
|
@@ -2230,16 +2267,32 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2230
2267
|
lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
|
|
2231
2268
|
}
|
|
2232
2269
|
|
|
2270
|
+
// Point the user at their grid (the console) after a deploy that is LIVE — new
|
|
2271
|
+
// or re-plug (#321). The app URL says where the app lives; the console line says
|
|
2272
|
+
// where the grid lives, so the common follow-up interaction (an edit) does not
|
|
2273
|
+
// end with a bare URL and no way back. The console root self-routes per account
|
|
2274
|
+
// state (sign-in wall → their grid → onboarding); there is no per-grid page to
|
|
2275
|
+
// link, so keep the root — a `/grids/<slug>` path 404s today.
|
|
2276
|
+
//
|
|
2277
|
+
// NOT on the building branch: the line says "view it live", and a still-building
|
|
2278
|
+
// app is not live — emitting it two lines under "Do NOT tell the user it is live"
|
|
2279
|
+
// would hand a model the word "live" next to a pending app. For an async runtime
|
|
2280
|
+
// build the console line arrives when liveness is confirmed, in runCheckDeploy's
|
|
2281
|
+
// success branch.
|
|
2282
|
+
if (data.entity_id && !isBuilding) {
|
|
2283
|
+
structured.console_url = CONSOLE_URL;
|
|
2284
|
+
lines.push(`You can view it live in your grid along with all the apps you build here: ${CONSOLE_URL}`);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2233
2287
|
// Visibility is the user's choice — never set silently. On a NEW deploy,
|
|
2234
2288
|
// surface the current visibility + the full option set and have the agent ASK
|
|
2235
2289
|
// the user, then apply their answer via grid_visibility. On an edit, leave the
|
|
2236
|
-
// entity's existing visibility untouched (don't re-ask on every re-plug)
|
|
2290
|
+
// entity's existing visibility untouched (don't re-ask on every re-plug) — this
|
|
2291
|
+
// stays `!isEdit` deliberately, decoupled from the console line above.
|
|
2237
2292
|
if (!isEdit && data.entity_id) {
|
|
2238
2293
|
const current = typeof data.visibility === "string" ? data.visibility : null;
|
|
2239
|
-
structured.console_url = CONSOLE_URL;
|
|
2240
2294
|
if (current) structured.current_visibility = current;
|
|
2241
2295
|
structured.visibility_options = VISIBILITY_OPTIONS.map((v) => ({ value: v, label: VISIBILITY_LABELS[v] }));
|
|
2242
|
-
lines.push(`Manage all your apps in your grid: ${CONSOLE_URL}`);
|
|
2243
2296
|
lines.push(
|
|
2244
2297
|
`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: ${
|
|
2245
2298
|
VISIBILITY_OPTIONS
|
|
@@ -2717,8 +2770,12 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
|
|
|
2717
2770
|
const url = ctx.state.lastDrop?.url;
|
|
2718
2771
|
if (verdict.status === "success") {
|
|
2719
2772
|
return {
|
|
2720
|
-
|
|
2721
|
-
|
|
2773
|
+
// The "now live" moment for an async runtime build lands here, not in
|
|
2774
|
+
// runPlug (which returned "building"). Point the user at their grid too
|
|
2775
|
+
// (#321) — this is where liveness is confirmed, so "view it live" is true.
|
|
2776
|
+
text: `Live${url ? `: ${url}` : ""} — the build finished. Give the user the URL.\n` +
|
|
2777
|
+
`You can view it live in your grid along with all the apps you build here: ${CONSOLE_URL}`,
|
|
2778
|
+
structured: { status: "success", live: true, ...(url ? { url } : {}), console_url: CONSOLE_URL },
|
|
2722
2779
|
};
|
|
2723
2780
|
}
|
|
2724
2781
|
if (verdict.status === "failed") {
|
package/src/tools/register.js
CHANGED
|
@@ -396,9 +396,15 @@ export function registerTools(server, ctx) {
|
|
|
396
396
|
"Re-plugging an anonymously-created drop needs its owner_token instead of sign-in.",
|
|
397
397
|
),
|
|
398
398
|
grid: z.string().optional().describe(
|
|
399
|
-
"On create: the grid slug to plug into
|
|
400
|
-
"
|
|
401
|
-
"
|
|
399
|
+
"On create: the grid slug to plug into. The destination grid is the USER'S CHOICE — treat it like " +
|
|
400
|
+
"visibility: NEVER infer one silently, and never reuse a previous or active grid without the user. If " +
|
|
401
|
+
"the user has more than one grid, CONFIRM which grid before plugging (omit this and grid_plug returns " +
|
|
402
|
+
"needs_grid with the grid list to choose from); a single-grid user is not asked. State the destination " +
|
|
403
|
+
"in the user's own terms (\"plugging into <grid>\") BEFORE the entity is created — a new entity in the " +
|
|
404
|
+
"wrong grid gets the wrong URL, is exposed to the wrong grid's members, and inherits the wrong datastore " +
|
|
405
|
+
"tier and namespace, and that is expensive to catch after the fact. On re-plug the entity never moves " +
|
|
406
|
+
"grids, but pass its home grid here when it differs from your active grid (the API checks your " +
|
|
407
|
+
"membership in the entity's grid). Anonymous → always the Guest grid.",
|
|
402
408
|
),
|
|
403
409
|
slug: z.string().optional().describe(
|
|
404
410
|
"Alternative RE-PLUG handle: paired with `grid`, resolves to an existing entity (the pickup " +
|
|
@@ -453,6 +459,10 @@ export function registerTools(server, ctx) {
|
|
|
453
459
|
"Without target_entity_id: CREATE a new entity with a new URL. " +
|
|
454
460
|
"With target_entity_id (or grid+slug): UPDATE the existing deployment IN PLACE, keeping the same URL — " +
|
|
455
461
|
"this is the only deploy/publish tool, so do not look for a separate 'update' or 'redeploy' tool. " +
|
|
462
|
+
"GRID CHOICE (new plug): the destination grid is the user's choice, like visibility — do NOT infer or " +
|
|
463
|
+
"reuse a grid silently. For a user with more than one grid, CONFIRM which grid before plugging (omit " +
|
|
464
|
+
"`grid` and grid_plug returns needs_grid to ask); a single-grid user is not asked. State the " +
|
|
465
|
+
"destination before the entity is created. On re-plug the grid is fixed by the entity — never re-asked. " +
|
|
456
466
|
"Sources (pass exactly one): `html` — a single self-contained HTML page (instant, any edition)" +
|
|
457
467
|
(ctx.edition === "web"
|
|
458
468
|
? " or `artifact_files` — a multi-file app inline. "
|
|
@@ -845,6 +855,7 @@ export function registerTools(server, ctx) {
|
|
|
845
855
|
status: z.string().describe("The build status: success | failed | building | queued | unknown."),
|
|
846
856
|
live: z.boolean().describe("True only when the build finished successfully and the URL serves."),
|
|
847
857
|
url: z.string().optional().describe("The live URL, when known."),
|
|
858
|
+
console_url: z.string().optional().describe("On success: URL to view the app live in the grid alongside all the user's apps."),
|
|
848
859
|
error: z.string().optional().describe("User-language failure reason, when the build failed."),
|
|
849
860
|
build_log_tail: z.string().optional().describe("Sanitized tail of the Cloud Build log (the real error), when the build failed."),
|
|
850
861
|
suggested_fix: z.string().optional().describe("A suggested fix for the failure, when the platform provides one."),
|