@cloudgrid-io/mcp 0.6.1 → 0.7.1

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/src/tools.js CHANGED
@@ -13,6 +13,7 @@ import { promisify } from "node:util";
13
13
  import { readFile } from "node:fs/promises";
14
14
  import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
15
15
  import { basename, dirname, resolve, join } from "node:path";
16
+ import { homedir } from "node:os";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import { z } from "zod";
18
19
  import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
@@ -200,7 +201,8 @@ const MIN_CLI_VERSION = "0.10.1";
200
201
  // The drift-guard test imports this and asserts every verb exists in `cloudgrid --help`.
201
202
  export const CLI_TOOL_VERBS = {
202
203
  cloudgrid_init: ["init"],
203
- cloudgrid_plug: ["plug"],
204
+ // cloudgrid_plug is NOT here: gridctl_plug is now a direct-API tool
205
+ // (POST /api/v2/plug, spec v2 §3), not a CLI wrapper.
204
206
  cloudgrid_logs: ["logs"],
205
207
  cloudgrid_share: ["visibility"],
206
208
  cloudgrid_feedback: ["feedback"],
@@ -292,16 +294,202 @@ function winQuoteArg(arg) {
292
294
  }
293
295
 
294
296
  // execFile a command that may be a Windows `.cmd` shim, safely on every OS.
295
- function execMaybeCmd(command, args, options) {
296
- if (!IS_WIN) return execFileAsync(command, args, options);
297
+ function execMaybeCmd(command, args, options, exec = execFileAsync) {
298
+ if (!IS_WIN) return exec(command, args, options);
297
299
  const line = [command, ...args].map(winQuoteArg).join(" ");
298
- return execFileAsync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", line], {
300
+ return exec(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", line], {
299
301
  ...options,
300
302
  windowsVerbatimArguments: true,
301
303
  });
302
304
  }
303
305
 
304
- async function runCloudgrid(args, opts = {}) {
306
+ // ── Electron-safe Node runtime resolution ─────────────────────────────────────
307
+ //
308
+ // Claude Desktop (and other Electron MCP hosts) run this server inside an
309
+ // Electron utility process, so `process.execPath` is the host's Electron helper
310
+ // binary — NOT a Node interpreter. Spawning it as if it were Node either dies
311
+ // with FATAL "Unable to find helper app" (Claude Desktop's helper binary) or
312
+ // boots a GUI Electron app that hangs until the exec timeout and then reports a
313
+ // fake success. ELECTRON_RUN_AS_NODE=1 only helps when the host's Electron
314
+ // build keeps the runAsNode fuse enabled — Claude Desktop's does NOT (verified:
315
+ // the helper FATALs identically with the variable set). So the bundled-CLI rung
316
+ // must (a) never blindly spawn process.execPath under Electron, (b) prefer a
317
+ // real Node binary probed from common install locations (the utility process
318
+ // PATH is a bare /usr/bin:/bin:/usr/sbin:/sbin on macOS), and (c) use
319
+ // execPath-as-node only after a quick verified run-as-node probe.
320
+
321
+ const CLI_SHIM = fileURLToPath(new URL("./cli-shim.mjs", import.meta.url));
322
+
323
+ const NO_NODE_HINT =
324
+ "No usable Node.js runtime found for spawning the CloudGrid CLI (this MCP " +
325
+ "host runs on Electron, whose binary cannot execute Node scripts). Install " +
326
+ "Node.js (https://nodejs.org) or the CloudGrid CLI (npm install -g " +
327
+ "@cloudgrid-io/cli) and try again.";
328
+
329
+ // True when `execPath` is a real Node interpreter we can hand a script to.
330
+ function execPathIsRealNode({ execPath, versions, platform }) {
331
+ if (versions?.electron) return false;
332
+ const name = basename(execPath).toLowerCase();
333
+ return platform === "win32" ? name === "node.exe" || name === "node" : name === "node";
334
+ }
335
+
336
+ // Pick the highest semver-looking entry of a directory (nvm/fnm version dirs).
337
+ function highestVersionEntry(dir, fsReaddir) {
338
+ try {
339
+ const versions = fsReaddir(dir)
340
+ .filter((d) => /^v?\d+\.\d+\.\d+$/.test(d))
341
+ .sort((a, b) => {
342
+ const pa = a.replace(/^v/, "").split(".").map(Number);
343
+ const pb = b.replace(/^v/, "").split(".").map(Number);
344
+ return pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2];
345
+ });
346
+ return versions.pop() || null;
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ // Candidate real-Node binaries, most likely first. GUI-launched Electron hosts
353
+ // get a bare PATH, so fixed install locations and version-manager dirs are
354
+ // probed explicitly.
355
+ function listNodeCandidates({ platform, env, home, fsReaddir }) {
356
+ const out = [];
357
+ const binName = platform === "win32" ? "node.exe" : "node";
358
+ for (const dir of (env.PATH || "").split(platform === "win32" ? ";" : ":")) {
359
+ if (dir) out.push(join(dir, binName));
360
+ }
361
+ if (platform === "win32") {
362
+ for (const base of [env.ProgramFiles, env["ProgramFiles(x86)"]]) {
363
+ if (base) out.push(join(base, "nodejs", "node.exe"));
364
+ }
365
+ if (env.NVM_SYMLINK) out.push(join(env.NVM_SYMLINK, "node.exe"));
366
+ if (env.LOCALAPPDATA) out.push(join(env.LOCALAPPDATA, "Volta", "bin", "node.exe"));
367
+ return out;
368
+ }
369
+ out.push("/usr/local/bin/node", "/opt/homebrew/bin/node", "/usr/bin/node", "/opt/local/bin/node");
370
+ if (home) {
371
+ out.push(join(home, ".volta", "bin", "node"));
372
+ const nvmVersions = join(env.NVM_DIR || join(home, ".nvm"), "versions", "node");
373
+ const nvmBest = highestVersionEntry(nvmVersions, fsReaddir);
374
+ if (nvmBest) out.push(join(nvmVersions, nvmBest, "bin", "node"));
375
+ for (const base of [
376
+ env.FNM_DIR,
377
+ join(home, ".local", "share", "fnm"),
378
+ join(home, "Library", "Application Support", "fnm"),
379
+ join(home, ".fnm"),
380
+ ]) {
381
+ if (!base) continue;
382
+ out.push(join(base, "aliases", "default", "bin", "node"));
383
+ const fnmVersions = join(base, "node-versions");
384
+ const fnmBest = highestVersionEntry(fnmVersions, fsReaddir);
385
+ if (fnmBest) out.push(join(fnmVersions, fnmBest, "installation", "bin", "node"));
386
+ }
387
+ }
388
+ return out;
389
+ }
390
+
391
+ // Verify a candidate actually behaves like Node (guards against broken shims
392
+ // and against Electron's fake-success mode where a timed-out GUI boot exits 0).
393
+ async function verifyNodeRuns(exec, command, env) {
394
+ try {
395
+ const { stdout } = await exec(command, ["-p", "process.version"], {
396
+ timeout: 5_000,
397
+ env,
398
+ stdio: ["ignore", "pipe", "pipe"],
399
+ });
400
+ return /^v\d+\./.test(String(stdout).trim());
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
405
+
406
+ // Resolve the Node runtime used to spawn the bundled CLI. Returns
407
+ // { command, kind } or null when no usable runtime exists.
408
+ //
409
+ // The `deps` seam exists ONLY so the Electron regression tests can drive the
410
+ // Electron code paths deterministically on a plain-Node CI box. Production
411
+ // (runCloudgrid) only ever injects the real spawner, so the result is memoized.
412
+ let runtimeCache; // undefined = not yet resolved; null = resolved to "none"
413
+ export async function resolveNodeRuntime(deps = {}) {
414
+ // Cache unless the environment itself is simulated (`exec` is just the
415
+ // spawner) — probing spawns children, and the answer cannot change within
416
+ // one server process.
417
+ const cacheable = Object.keys(deps).every((k) => k === "exec");
418
+ if (cacheable && runtimeCache !== undefined) return runtimeCache;
419
+ const {
420
+ exec = execFileAsync,
421
+ execPath = process.execPath,
422
+ versions = process.versions,
423
+ platform = process.platform,
424
+ env = process.env,
425
+ home = homedir(),
426
+ fsExists = existsSync,
427
+ fsReaddir = readdirSync,
428
+ } = deps;
429
+
430
+ let result = null;
431
+ if (execPathIsRealNode({ execPath, versions, platform })) {
432
+ result = { command: execPath, kind: "exec-path" };
433
+ } else {
434
+ // 1. A real Node binary somewhere on this machine.
435
+ for (const candidate of listNodeCandidates({ platform, env, home, fsReaddir })) {
436
+ if (!fsExists(candidate)) continue;
437
+ if (await verifyNodeRuns(exec, candidate, env)) {
438
+ result = { command: candidate, kind: "system-node" };
439
+ break;
440
+ }
441
+ }
442
+ // 2. Last resort: execPath with ELECTRON_RUN_AS_NODE — only if a quick
443
+ // probe proves the host's runAsNode fuse is enabled (Claude Desktop's
444
+ // is not; vanilla Electron's is).
445
+ if (!result) {
446
+ const probeEnv = { ...env, ELECTRON_RUN_AS_NODE: "1" };
447
+ if (await verifyNodeRuns(exec, execPath, probeEnv)) {
448
+ result = { command: execPath, kind: "electron-run-as-node" };
449
+ }
450
+ }
451
+ }
452
+ if (cacheable) runtimeCache = result;
453
+ return result;
454
+ }
455
+
456
+ // A step-1 error that means "the runtime never ran the CLI" (fall through to
457
+ // the global-CLI/npx rungs) as opposed to "the CLI ran and reported an error"
458
+ // (surface it).
459
+ function isRuntimeBootFailure(err) {
460
+ if (!err) return false;
461
+ if (err.code === "ENOENT" || err.code === "EACCES") return true;
462
+ const text = `${err.stderr || ""}\n${err.message || ""}`;
463
+ return text.includes("Unable to find helper app") || /FATAL:.*electron/i.test(text);
464
+ }
465
+
466
+ // Environment for the global-CLI and npx fallback rungs. GUI-launched Electron
467
+ // hosts hand utility processes a bare PATH (macOS: /usr/bin:/bin:/usr/sbin:/sbin)
468
+ // that misses Homebrew/npm bin dirs, so `cloudgrid`/`npx` would be ENOENT even
469
+ // when installed — append the usual install locations. ELECTRON_RUN_AS_NODE is
470
+ // stripped so it cannot leak through a real-node CLI into anything it spawns.
471
+ function pathAugmentedEnv(env = process.env, platform = process.platform, home = homedir()) {
472
+ const out = { ...env };
473
+ delete out.ELECTRON_RUN_AS_NODE;
474
+ if (platform === "win32") return out;
475
+ const extras = ["/usr/local/bin", "/opt/homebrew/bin", join(home, ".volta", "bin"), join(home, ".local", "bin")];
476
+ const parts = (out.PATH || "").split(":").filter(Boolean);
477
+ for (const dir of extras) {
478
+ if (!parts.includes(dir)) parts.push(dir);
479
+ }
480
+ out.PATH = parts.join(":");
481
+ return out;
482
+ }
483
+
484
+ // The `deps` seam mirrors resolveBundledCli's: it exists ONLY so regression
485
+ // tests can spy the exec calls and simulate Electron hosts deterministically.
486
+ // Production always calls this with no deps.
487
+ export async function runCloudgrid(args, opts = {}, deps = {}) {
488
+ const {
489
+ exec = execFileAsync,
490
+ resolveCli = resolveBundledCli,
491
+ resolveRuntime = resolveNodeRuntime,
492
+ } = deps;
305
493
  const cwd = resolveCwd(opts.cwd);
306
494
  const execOpts = {
307
495
  maxBuffer: 16 * 1024 * 1024,
@@ -309,6 +497,11 @@ async function runCloudgrid(args, opts = {}) {
309
497
  stdio: ["ignore", "pipe", "pipe"],
310
498
  ...(cwd ? { cwd } : {}),
311
499
  };
500
+ // PATH-augmented, ELECTRON_RUN_AS_NODE-free env for the fallback rungs.
501
+ const fallbackEnv = pathAugmentedEnv();
502
+ // Set when the bundled CLI had to be skipped for lack of a Node runtime, so
503
+ // the final error names the real cause instead of a raw npx/Electron error.
504
+ let runtimeHint = null;
312
505
 
313
506
  const extract = (err) => {
314
507
  const detail = [err && err.stdout, err && err.stderr, err && err.message]
@@ -318,18 +511,35 @@ async function runCloudgrid(args, opts = {}) {
318
511
  return new Error(detail || "cloudgrid command failed");
319
512
  };
320
513
 
321
- // 1. Bundled CLI — own node_modules only, version-gated
322
- const bundled = resolveBundledCli();
514
+ // 1. Bundled CLI — own node_modules only, version-gated. Runs via
515
+ // cli-shim.mjs under a verified Node runtime (see resolveNodeRuntime):
516
+ // process.execPath is NOT assumed to be Node, because under Electron MCP
517
+ // hosts (Claude Desktop) it is the host's Electron helper binary.
518
+ // ELECTRON_RUN_AS_NODE=1 makes an Electron runtime boot as Node and is
519
+ // ignored by plain Node; the shim strips it again before the CLI runs and
520
+ // fixes commander's Electron argv slicing.
521
+ const bundled = resolveCli();
323
522
  if (bundled && meetsMinVersion(bundled.version)) {
324
- try {
325
- const { stdout, stderr } = await execFileAsync(
326
- process.execPath,
327
- [bundled.entry, ...args],
328
- execOpts,
329
- );
330
- return (stdout || stderr || "").trim() || "Done.";
331
- } catch (err) {
332
- throw extract(err);
523
+ const runtime = await resolveRuntime({ exec });
524
+ if (runtime) {
525
+ try {
526
+ const { stdout, stderr } = await exec(
527
+ runtime.command,
528
+ [CLI_SHIM, bundled.entry, ...args],
529
+ { ...execOpts, env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } },
530
+ );
531
+ return (stdout || stderr || "").trim() || "Done.";
532
+ } catch (err) {
533
+ // A CLI-reported error surfaces; a runtime that failed to boot falls
534
+ // through to the global-CLI/npx rungs instead of killing the chain.
535
+ if (!isRuntimeBootFailure(err)) throw extract(err);
536
+ console.error(
537
+ `cloudgrid-mcp: bundled CLI spawn failed (${err?.code || "runtime boot failure"}); trying global CLI, then npx`,
538
+ );
539
+ }
540
+ } else {
541
+ runtimeHint = NO_NODE_HINT;
542
+ console.error(`cloudgrid-mcp: ${NO_NODE_HINT} Trying global CLI, then npx.`);
333
543
  }
334
544
  } else if (bundled) {
335
545
  console.error(
@@ -343,10 +553,16 @@ async function runCloudgrid(args, opts = {}) {
343
553
  const globalBin = IS_WIN ? "cloudgrid.cmd" : "cloudgrid";
344
554
  let useGlobal = false;
345
555
  try {
346
- const { stdout: vOut } = await execMaybeCmd(globalBin, ["--version"], {
347
- timeout: 5_000,
348
- stdio: ["ignore", "pipe", "pipe"],
349
- });
556
+ const { stdout: vOut } = await execMaybeCmd(
557
+ globalBin,
558
+ ["--version"],
559
+ {
560
+ timeout: 5_000,
561
+ stdio: ["ignore", "pipe", "pipe"],
562
+ env: fallbackEnv,
563
+ },
564
+ exec,
565
+ );
350
566
  const ver = vOut.trim().match(/(\d+\.\d+\.\d+)/)?.[1];
351
567
  if (meetsMinVersion(ver)) {
352
568
  useGlobal = true;
@@ -360,7 +576,12 @@ async function runCloudgrid(args, opts = {}) {
360
576
  }
361
577
  if (useGlobal) {
362
578
  try {
363
- const { stdout, stderr } = await execMaybeCmd(globalBin, args, execOpts);
579
+ const { stdout, stderr } = await execMaybeCmd(
580
+ globalBin,
581
+ args,
582
+ { ...execOpts, env: fallbackEnv },
583
+ exec,
584
+ );
364
585
  return (stdout || stderr || "").trim() || "Done.";
365
586
  } catch (err) {
366
587
  throw extract(err);
@@ -378,11 +599,14 @@ async function runCloudgrid(args, opts = {}) {
378
599
  const { stdout, stderr } = await execMaybeCmd(
379
600
  npx,
380
601
  ["-y", CLI_NPX_PKG, ...args],
381
- execOpts,
602
+ { ...execOpts, env: fallbackEnv },
603
+ exec,
382
604
  );
383
605
  return (stdout || stderr || "").trim() || "Done.";
384
606
  } catch (err) {
385
- throw extract(err);
607
+ const failure = extract(err);
608
+ if (runtimeHint) failure.message = `${runtimeHint}\n\n${failure.message}`;
609
+ throw failure;
386
610
  }
387
611
  }
388
612
 
@@ -406,8 +630,13 @@ function tryOpenBrowser(url) {
406
630
  if (process.env.CLOUDGRID_NO_BROWSER === "1") return;
407
631
  const cmd =
408
632
  process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
633
+ // Strip ELECTRON_RUN_AS_NODE (present when the server itself runs under
634
+ // Electron-as-Node) so an Electron-based browser doesn't boot as a headless
635
+ // Node process instead of opening a window.
636
+ const env = { ...process.env };
637
+ delete env.ELECTRON_RUN_AS_NODE;
409
638
  try {
410
- execFile(cmd, [url], () => {});
639
+ execFile(cmd, [url], { env }, () => {});
411
640
  } catch {
412
641
  // ignore — the URL is returned to the user anyway
413
642
  }
@@ -466,10 +695,14 @@ function looksLikeFullHtml(s) {
466
695
  // the public URL hangs off (https://guest.cloudgrid.io/<slug>).
467
696
  const GUEST_ORG_SLUG = "guest";
468
697
 
469
- // Compose an entity's public URL from the `/api/v2/plug` response. Unlike the
470
- // retired `/drop/auto`, `/plug` does NOT return a `url` field — it returns
471
- // `slug` + `grid` (+ a `detection.kind`) and expects the client to derive the
472
- // canonical URL, mirroring the platform's `entityUrl()` URL derivation:
698
+ // FALLBACK-ONLY URL composition. Since the unified plug contract (spec v2 /
699
+ // the unified plug spec), `/api/v2/plug` returns a server-composed canonical `url` on every
700
+ // path (create + edit, anon + authed) — flat-arch-aware per grid, matching the
701
+ // host that actually serves. ALWAYS prefer `data.url` (see resolvePlugUrl);
702
+ // this client-side derivation exists only for the rare response where `url`
703
+ // came back empty (the server composes it best-effort). It mirrors the legacy
704
+ // `entityUrl()` rules and is WRONG on flat-arch grids (which serve
705
+ // `<slug>--<grid>.cloudgrid.io`), so it must never be the primary source:
473
706
  // - inspiration (HTML drops): path-based at the org apex
474
707
  // https://<grid>.cloudgrid.io/<slug>
475
708
  // - runtime (app/agent): subdomain
@@ -488,7 +721,18 @@ function composePlugUrl(data) {
488
721
  return `https://${grid}.cloudgrid.io/${slug}`;
489
722
  }
490
723
 
491
- async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fresh }) {
724
+ // The public URL of a `/plug` response: the server-composed `url` verbatim
725
+ // (canonical, flat-arch-aware — the unified plug spec), falling back to client-side composition
726
+ // ONLY when the server left it empty (its composition is best-effort).
727
+ export function resolvePlugUrl(data) {
728
+ if (typeof data?.url === "string" && data.url.length > 0) return data.url;
729
+ return composePlugUrl(data);
730
+ }
731
+
732
+ export async function runDrop(
733
+ ctx,
734
+ { html, path: filePath, filename, anonymous, org, fresh, entity_id, owner_token },
735
+ ) {
492
736
  // Defensive: the web edition schema excludes `path`, but if a model still
493
737
  // passes one (e.g. from a cached tool description), reject early with a
494
738
  // clear explanation.
@@ -526,44 +770,103 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
526
770
  throw new Error("Provide either `html` (inline content) or `path` (a local file).");
527
771
  }
528
772
 
529
- const headers = {};
773
+ // The caller's authed identity (unless they force an anonymous drop).
774
+ let authToken = null;
530
775
  let orgSlug = null;
531
776
  if (anonymous !== true) {
532
- const token = await ctx.getToken();
533
- if (token) {
534
- headers["Authorization"] = `Bearer ${token}`;
777
+ authToken = await ctx.getToken();
778
+ if (authToken) {
535
779
  orgSlug = org || (await ctx.getActiveOrg());
536
- if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
537
780
  }
538
781
  }
539
782
 
540
- // Hosted server: attach the trusted-server credential when available.
541
- // Falls back gracefully server-side if absent. Only the web edition sets ctx.trustedServer.
542
- if (!headers["Authorization"] && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
783
+ // ── Re-plug targeting (unified plug contract / spec v2) ──────
784
+ // A `target_entity_id` on the wire UPDATES THAT SAME ENTITY in place — same
785
+ // entity_id, same slug, same URL, deploy history preserved. Absent → create.
786
+ // The session remembers the last drop, so a re-drop updates it by default;
787
+ // `fresh: true` forces a create; an explicit `entity_id` targets any earlier
788
+ // drop (the durable re-plug handle a stateless caller persisted).
789
+ if (fresh === true && entity_id) {
790
+ throw new Error("`fresh: true` forces a new drop — do not pass `entity_id` with it.");
791
+ }
792
+ const sessionDrop = ctx.state.lastDrop;
793
+ let targetId = null;
794
+ let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
795
+ if (fresh !== true) {
796
+ targetId = entity_id || sessionDrop?.entity_id || null;
797
+ // An anon-minted drop is edited with its OWNER TOKEN (a bearer capability,
798
+ // the anon owner-token contract). Recover it from session state when not passed.
799
+ if (targetId && !ownerToken) {
800
+ if (sessionDrop?.entity_id === targetId && sessionDrop.owner_token) {
801
+ ownerToken = sessionDrop.owner_token;
802
+ } else if (ctx.state.lastAnonClaim?.entity_id === targetId && ctx.state.lastAnonClaim.token) {
803
+ ownerToken = ctx.state.lastAnonClaim.token;
804
+ }
805
+ }
806
+ }
807
+
808
+ // Pick the edit wire. An anon-owned (Guest-Grid, unclaimed) drop is edited via
809
+ // the owner-token wire EVEN IF the caller is signed in — the entity still lives
810
+ // in the Guest Grid, so an authed edit of it would 404. An authed edit targets
811
+ // an entity the caller owns in their grid.
812
+ let mode = "create"; // "create" | "authed-edit" | "anon-edit"
813
+ if (targetId) {
814
+ if (ownerToken) {
815
+ mode = "anon-edit";
816
+ } else if (authToken) {
817
+ mode = "authed-edit";
818
+ } else if (entity_id) {
819
+ throw new Error(
820
+ "Re-plugging a drop anonymously needs its owner_token (returned when it was created). " +
821
+ "Pass owner_token, sign in if you own it, or pass fresh: true to publish a new drop.",
822
+ );
823
+ } else {
824
+ // Session target, but no way to authorize an edit — fall back to create.
825
+ targetId = null;
826
+ }
827
+ }
828
+ const isAnonymousWire = mode === "anon-edit" || !authToken;
829
+
830
+ const headers = {};
831
+ if (!isAnonymousWire) {
832
+ headers["Authorization"] = `Bearer ${authToken}`;
833
+ if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
834
+ }
835
+
836
+ // Hosted server: attach the trusted-server credential on EVERY anonymous call
837
+ // (creates AND owner-token edits — anon edits consume the same daily anon cap,
838
+ // re-keyed per end user). Falls back gracefully server-side if absent. Only
839
+ // the web edition sets ctx.trustedServer.
840
+ if (isAnonymousWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
543
841
  headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
544
842
  headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
545
843
  }
546
844
 
547
- const isAnonymousCall = !headers["Authorization"];
548
-
549
845
  // Ownership continuity: replay the platform's anon-session cookie across drops in
550
846
  // this session, so cookie-class callers can redrop (and claim) what they dropped.
551
- if (isAnonymousCall && ctx.state.anonCookie) {
847
+ if (isAnonymousWire && ctx.state.anonCookie) {
552
848
  headers["Cookie"] = ctx.state.anonCookie;
553
849
  }
554
850
 
555
851
  const form = new FormData();
556
- // The unified `/plug` create path is the same orchestrator `/drop/auto` used,
557
- // but it is CREATE-ONLY for the drop flow: an anonymous caller naming a
558
- // `target_entity_id` is rejected 401, and the authed create path mints a NEW
559
- // entity. So `/plug` has NO in-place redrop / `previous_id` concept — every
560
- // drop creates a fresh entity (new URL). `fresh` is accepted for backward
561
- // compatibility but is now a no-op. See PR notes: the in-place-update +
562
- // `202 unchanged` no-op semantics from `/drop/auto` are gone with M7.
563
- void fresh;
564
852
  // The artifact part name is unchanged from `/drop/auto` (`artifact`); the plug
565
- // create path treats every non-`cloudgrid.yaml` part as raw artifact bytes.
853
+ // create path — and the inspiration edit paths — treat every
854
+ // non-`cloudgrid.yaml` part as raw artifact bytes.
566
855
  form.append("artifact", new Blob([bytes], { type }), name);
856
+ if (mode !== "create") {
857
+ form.append("target_entity_id", targetId);
858
+ }
859
+ if (mode === "anon-edit") {
860
+ // Anon owner-token contract: ownership is proven by HOLDING the token (a form
861
+ // field, NOT an Authorization header). The response re-mints it.
862
+ form.append("owner_token", ownerToken);
863
+ }
864
+ if (mode === "authed-edit") {
865
+ // The authed update path requires a `cloudgrid.yaml` part on the wire
866
+ // (materializePlugTarball). An inspiration edit ignores its content, so an
867
+ // empty part satisfies the contract without changing the entity's config.
868
+ form.append("cloudgrid.yaml", new Blob([""], { type: "text/plain" }), "cloudgrid.yaml");
869
+ }
567
870
  // `/plug` resolves the authed org from the `X-CloudGrid-Org` header (set
568
871
  // above), not a form field — so `org_slug` is no longer sent.
569
872
 
@@ -584,6 +887,21 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
584
887
  if (!res.ok) {
585
888
  const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
586
889
  const hint = data?.error?.details?.[0]?.hint;
890
+ // An explicit edit NEVER silently creates — surface the reason clearly.
891
+ if (mode !== "create" && res.status === 409) {
892
+ throw new Error(
893
+ `Re-plug rejected (HTTP 409): ${msg} ` +
894
+ "The drop can no longer be edited in place (expired, archived, claimed, or a deploy is running). " +
895
+ "Pass fresh: true to publish a new drop instead.",
896
+ );
897
+ }
898
+ if (mode === "anon-edit" && res.status === 401) {
899
+ throw new Error(
900
+ `Re-plug rejected (HTTP 401): ${msg} ` +
901
+ "The owner_token did not authorize this entity (wrong entity, expired with the drop, or already claimed). " +
902
+ "Pass fresh: true to publish a new drop instead.",
903
+ );
904
+ }
587
905
  throw new Error(`Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`);
588
906
  }
589
907
 
@@ -597,33 +915,59 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
597
915
  .find((c) => c.startsWith("cg_anon_session="));
598
916
  if (anonCookie) ctx.state.anonCookie = anonCookie;
599
917
 
600
- // `/plug` returns no `url`; derive the canonical public URL from slug + grid.
601
- const url = composePlugUrl(data);
918
+ // The server composes the canonical URL; compose client-side only as
919
+ // a fallback when it came back empty.
920
+ const url = resolvePlugUrl(data);
921
+ const isEdit = mode !== "create";
922
+
923
+ // The anon owner token: the create response mints it; every anon edit
924
+ // RE-MINTS it to the reset TTL (replace the stored one — the freshest token
925
+ // is the one that lives as long as the drop). Same JWT the claim uses.
926
+ let freshOwnerToken = typeof data.owner_token === "string" && data.owner_token.length > 0
927
+ ? data.owner_token
928
+ : null;
929
+ if (!freshOwnerToken && data.claim_url) {
930
+ try {
931
+ freshOwnerToken = new URL(data.claim_url).searchParams.get("token");
932
+ } catch {
933
+ freshOwnerToken = null;
934
+ }
935
+ }
602
936
 
603
- // Remember the drop for session continuity — any caller class.
937
+ // Remember the drop for session continuity — any caller class. The
938
+ // `{entity_id, owner_token}` pair is the stateless anon re-plug handle.
604
939
  if (data.entity_id || url) {
605
940
  ctx.state.lastDrop = {
606
941
  entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
607
942
  url: url ?? ctx.state.lastDrop?.url ?? null,
943
+ owner_token: isAnonymousWire
944
+ ? (freshOwnerToken ?? (mode === "anon-edit" ? ownerToken : null))
945
+ : null,
608
946
  };
609
947
  }
610
948
 
611
- // Authenticated create (202): the drop minted an entity owned by the caller.
612
- // `/plug`'s authed branch is reached only when an Authorization header was
613
- // sent, so distinguish on the call class rather than a (now-absent) `owned_by`.
614
- if (!isAnonymousCall) {
949
+ // Authenticated wire (create or in-place edit): the entity is owned by the caller.
950
+ if (!isAnonymousWire) {
615
951
  ctx.state.lastAnonClaim = null;
616
- const lines = ctx.edition === "web"
617
- ? [`Your app is live: ${url}`]
618
- : [`Published to your org: ${url}`, "Owned by you."];
619
952
  const structured = {
620
953
  url,
621
- status: "created",
954
+ status: isEdit ? "updated" : "created",
622
955
  owned_by: "authenticated",
956
+ ...(data.entity_id ? { entity_id: data.entity_id } : {}),
623
957
  };
624
- if (ctx.edition === "web") {
625
- // Default authed web drops to "link" visibility so the URL is shareable
626
- // and the console thumbnail renders without a sign-in wall.
958
+ const lines = [];
959
+ if (isEdit) {
960
+ lines.push(`Updated in place: ${url}`);
961
+ lines.push("Same link, new content. The expiry timer was reset.");
962
+ } else if (ctx.edition === "web") {
963
+ lines.push(`Your app is live: ${url}`);
964
+ } else {
965
+ lines.push(`Published to your org: ${url}`, "Owned by you.");
966
+ }
967
+ if (ctx.edition === "web" && !isEdit) {
968
+ // Default authed web CREATES to "link" visibility so the URL is shareable
969
+ // and the console thumbnail renders without a sign-in wall. An edit keeps
970
+ // the entity's existing visibility untouched.
627
971
  if (data.entity_id) {
628
972
  await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
629
973
  }
@@ -637,33 +981,41 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
637
981
  return { text: lines.join("\n"), structured };
638
982
  }
639
983
 
640
- // Anonymous create (201) — Guest-Org inspiration, 7-day expiry, claimable.
641
- // `/plug` carries the reward fields (`claim_url` + `claim_message`) and the
642
- // real `entity_id`; pickup-by-id is how it is later claimed.
984
+ // Anonymous wire — Guest-Grid inspiration (create 201, or owner-token edit).
985
+ // The response carries the reward fields (`claim_url` + `claim_message`), the
986
+ // real `entity_id`, and the OWNER TOKEN — the bearer capability that
987
+ // authorizes BOTH a later anonymous re-plug and the claim (pickup-by-id).
643
988
  if (data.claim_url || data.entity_id) {
644
- let token = null;
645
- try {
646
- token = data.claim_url ? new URL(data.claim_url).searchParams.get("token") : null;
647
- } catch {
648
- token = null;
649
- }
650
989
  ctx.state.lastAnonClaim = {
651
- token,
652
- entity_id: data.entity_id ?? null,
990
+ token: freshOwnerToken,
991
+ entity_id: data.entity_id ?? ctx.state.lastAnonClaim?.entity_id ?? null,
653
992
  url,
654
993
  };
655
994
  }
656
- const lines = [ctx.edition === "web" ? `Your app is live: ${url}` : `Live: ${url}`];
995
+ const lines = [];
996
+ if (isEdit) {
997
+ lines.push(`Updated in place: ${url}`);
998
+ lines.push("Same link, new content. The expiry timer (and owner_token) were refreshed.");
999
+ } else {
1000
+ lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Live: ${url}`);
1001
+ }
657
1002
  if (data.claim_message) {
658
1003
  lines.push(data.claim_message);
659
1004
  } else if (data.claim_url) {
660
- lines.push("Sign in, then run gridctl_claim to keep it past 7 days.");
1005
+ lines.push("Sign in, then run gridctl_claim to keep it past the expiry window.");
1006
+ }
1007
+ if (data.entity_id && freshOwnerToken) {
1008
+ lines.push(
1009
+ `Re-plug handle (persist to update or claim this drop in a later session): entity_id=${data.entity_id}`,
1010
+ );
661
1011
  }
662
1012
  return {
663
1013
  text: lines.join("\n"),
664
1014
  structured: {
665
1015
  url,
666
- status: "created",
1016
+ status: isEdit ? "updated" : "created",
1017
+ ...(data.entity_id ? { entity_id: data.entity_id } : {}),
1018
+ ...(freshOwnerToken ? { owner_token: freshOwnerToken } : {}),
667
1019
  },
668
1020
  };
669
1021
  }
@@ -678,7 +1030,10 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
678
1030
  // claim now runs through `POST /api/v2/entities/:id/pickup`, which takes the
679
1031
  // entity id in the PATH and the `claim_token` in the body, and re-homes the
680
1032
  // Guest-Org inspiration into the caller's grid (ownership transfer). The
681
- // anon-session cookie is the token-less alternative auth.
1033
+ // claim token IS the anonymous owner token (Anon owner-token contract: one bearer
1034
+ // capability for both edit and claim); an anon edit re-mints it, and the
1035
+ // session state always holds the freshest one. The anon-session cookie is the
1036
+ // token-less alternative auth.
682
1037
  let claimToken = claim_token;
683
1038
  if (!claimToken && claim_url) {
684
1039
  try {
@@ -691,7 +1046,14 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
691
1046
 
692
1047
  // Pickup needs the target entity id in the URL path. Prefer an explicit one,
693
1048
  // else the entity remembered from this session's anonymous drop.
694
- const targetId = entity_id || ctx.state.lastAnonClaim?.entity_id;
1049
+ const targetId =
1050
+ entity_id ||
1051
+ ctx.state.lastAnonClaim?.entity_id ||
1052
+ // The last drop counts only when it is anon-owned (it holds an owner token).
1053
+ (ctx.state.lastDrop?.owner_token ? ctx.state.lastDrop.entity_id : null);
1054
+ if (!claimToken && targetId && ctx.state.lastDrop?.entity_id === targetId) {
1055
+ claimToken = ctx.state.lastDrop.owner_token ?? null;
1056
+ }
695
1057
  if (!targetId) {
696
1058
  throw new Error(
697
1059
  "No drop to claim. Pass entity_id (or drop something anonymously first in this session), " +
@@ -744,6 +1106,12 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
744
1106
  }
745
1107
 
746
1108
  ctx.state.lastAnonClaim = null;
1109
+ // The entity is authed-owned now — the anon owner token is dead weight (a
1110
+ // claimed drop can no longer be edited anonymously). Future re-drops of it
1111
+ // must ride the authed wire.
1112
+ if (ctx.state.lastDrop?.entity_id === targetId) {
1113
+ ctx.state.lastDrop.owner_token = null;
1114
+ }
747
1115
  const url = data?.url || data?.redirect_url || ctx.state.lastDrop?.url || null;
748
1116
  const lines = ["Claimed 1, now yours:"];
749
1117
  lines.push(`${url ?? ""}${data?.new_expires_at ? ` (expires ${data.new_expires_at})` : ""}`.trim());
@@ -757,6 +1125,453 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
757
1125
  }
758
1126
 
759
1127
 
1128
+ // ── gridctl_plug — the unified create/re-plug verb (spec v2 §3) ──────────────
1129
+
1130
+ // Total upload budget mirrors the server's multipart cap (100 MB).
1131
+ const PLUG_MAX_TOTAL_BYTES = 100 * 1024 * 1024;
1132
+ const PLUG_MAX_FILES = 2000;
1133
+
1134
+ // Directories never worth uploading, regardless of ignore files.
1135
+ const PLUG_ALWAYS_SKIP = new Set([".git", "node_modules", ".DS_Store", ".cloudgrid"]);
1136
+
1137
+ // Compile one .gitignore/.cloudgridignore pattern into a matcher over
1138
+ // repo-relative paths. A pragmatic subset (no negation `!` — those lines are
1139
+ // skipped): `#` comments, `*`/`?`/`**` globs, a leading `/` anchors to the
1140
+ // root, a trailing `/` matches directories only, and a bare name matches at
1141
+ // any depth (standard gitignore semantics for patterns without a slash).
1142
+ function compileIgnorePattern(line) {
1143
+ let pat = line.trim();
1144
+ if (!pat || pat.startsWith("#") || pat.startsWith("!")) return null;
1145
+ const dirOnly = pat.endsWith("/");
1146
+ if (dirOnly) pat = pat.slice(0, -1);
1147
+ // A pattern containing a slash is anchored to the root (gitignore rule).
1148
+ const anchored = pat.includes("/");
1149
+ if (pat.startsWith("/")) pat = pat.slice(1);
1150
+ const rx = pat
1151
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
1152
+ .replace(/\*\*/g, "")
1153
+ .replace(/\*/g, "[^/]*")
1154
+ .replace(/\?/g, "[^/]")
1155
+ .replace(//g, ".*");
1156
+ const body = anchored ? `^${rx}` : `(^|/)${rx}`;
1157
+ const re = new RegExp(`${body}(/|$)`);
1158
+ return { re, dirOnly };
1159
+ }
1160
+
1161
+ function loadIgnoreMatchers(rootDir) {
1162
+ const patterns = [];
1163
+ for (const f of [".gitignore", ".cloudgridignore"]) {
1164
+ try {
1165
+ const p = join(rootDir, f);
1166
+ if (existsSync(p)) {
1167
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
1168
+ const compiled = compileIgnorePattern(line);
1169
+ if (compiled) patterns.push(compiled);
1170
+ }
1171
+ }
1172
+ } catch {
1173
+ /* unreadable ignore file — upload everything */
1174
+ }
1175
+ }
1176
+ return (relPath, isDir) =>
1177
+ patterns.some((p) => (!p.dirOnly || isDir) && p.re.test(relPath));
1178
+ }
1179
+
1180
+ // Walk a local folder into `[{path, buffer}]` artifacts (repo-relative paths),
1181
+ // honoring .gitignore/.cloudgridignore at the root plus the always-skip set.
1182
+ // A single file becomes one artifact named by its basename.
1183
+ function collectPathArtifacts(srcPath) {
1184
+ const abs = resolve(srcPath);
1185
+ if (!existsSync(abs)) throw new Error(`Path does not exist: ${abs}`);
1186
+ const st = statSync(abs);
1187
+ if (st.isFile()) {
1188
+ return [{ path: basename(abs), buffer: readFileSync(abs) }];
1189
+ }
1190
+ if (!st.isDirectory()) throw new Error(`Not a file or directory: ${abs}`);
1191
+ const isIgnored = loadIgnoreMatchers(abs);
1192
+ const out = [];
1193
+ let total = 0;
1194
+ const walk = (dir, rel) => {
1195
+ for (const nm of readdirSync(dir)) {
1196
+ if (PLUG_ALWAYS_SKIP.has(nm)) continue;
1197
+ const childAbs = join(dir, nm);
1198
+ const childRel = rel ? `${rel}/${nm}` : nm;
1199
+ let cst;
1200
+ try {
1201
+ cst = statSync(childAbs);
1202
+ } catch {
1203
+ continue; // broken symlink etc.
1204
+ }
1205
+ if (isIgnored(childRel, cst.isDirectory())) continue;
1206
+ if (cst.isDirectory()) {
1207
+ walk(childAbs, childRel);
1208
+ } else if (cst.isFile()) {
1209
+ if (out.length >= PLUG_MAX_FILES) {
1210
+ throw new Error(
1211
+ `The folder has more than ${PLUG_MAX_FILES} files after ignores — too large to plug inline. Trim it or add a .cloudgridignore.`,
1212
+ );
1213
+ }
1214
+ total += cst.size;
1215
+ if (total > PLUG_MAX_TOTAL_BYTES) {
1216
+ throw new Error("The upload exceeds the 100MB plug limit. Trim the folder or add a .cloudgridignore.");
1217
+ }
1218
+ out.push({ path: childRel, buffer: readFileSync(childAbs) });
1219
+ }
1220
+ }
1221
+ };
1222
+ walk(abs, "");
1223
+ if (out.length === 0) throw new Error(`Nothing to upload in ${abs} (everything ignored or empty).`);
1224
+ return out;
1225
+ }
1226
+
1227
+ // Map friendly plug error statuses to actionable messages (spec v2 §3.3).
1228
+ function plugErrorMessage(status, code, msg) {
1229
+ const base = `Plug failed (HTTP ${status}${code ? ` ${code}` : ""}): ${msg}`;
1230
+ if (status === 409) {
1231
+ return `${base} — the entity cannot be updated right now (a deploy is in progress, or it is archived/expired/claimed). An explicit re-plug never silently creates; retry later or omit target_entity_id to create a new entity.`;
1232
+ }
1233
+ if (status === 401) {
1234
+ return `${base} — sign in (gridctl_login), or for an anon-minted drop pass its owner_token.`;
1235
+ }
1236
+ if (status === 403) {
1237
+ return `${base} — you lack the role to plug this target. To re-plug someone else's entity, pick it up first (gridctl_pickup / gridctl_claim).`;
1238
+ }
1239
+ if (status === 429) {
1240
+ return `${base} — the daily anonymous cap was reached (anon edits share the create cap). Sign in to publish more, or retry tomorrow.`;
1241
+ }
1242
+ return base;
1243
+ }
1244
+
1245
+ /**
1246
+ * The unified create/re-plug verb (spec v2 §3). Two intents on one tool, keyed
1247
+ * by `target_entity_id`:
1248
+ * - absent → CREATE: mint a new entity from the artifact (server detection
1249
+ * decides the kind unless hinted). Authed → the caller's grid; anon → a
1250
+ * Guest-Grid drop with a claim_url + owner_token.
1251
+ * - present → RE-PLUG: update the SAME entity in place (same id, slug, URL,
1252
+ * history). Authed for entities in your grid; `owner_token` for a drop
1253
+ * minted anonymously (anon owner-token contract).
1254
+ * Source is `path` (local edition — the folder/file is read and uploaded) XOR
1255
+ * `artifact_files` (hosted — inline file entries).
1256
+ */
1257
+ export async function runPlug(ctx, input) {
1258
+ const {
1259
+ path: srcPath,
1260
+ artifact_files,
1261
+ cloudgrid_yaml,
1262
+ target_entity_id,
1263
+ grid,
1264
+ hints,
1265
+ anon,
1266
+ owner_token,
1267
+ } = input || {};
1268
+
1269
+ // ── Source: path XOR artifact_files ────────────────────────────────────────
1270
+ if (srcPath && Array.isArray(artifact_files) && artifact_files.length > 0) {
1271
+ throw new Error("Pass either `path` or `artifact_files`, not both.");
1272
+ }
1273
+ if (ctx.edition === "web" && srcPath) {
1274
+ throw new Error(
1275
+ "The hosted server cannot read local files — pass the source inline via `artifact_files`.",
1276
+ );
1277
+ }
1278
+ let artifacts;
1279
+ if (srcPath) {
1280
+ artifacts = collectPathArtifacts(srcPath);
1281
+ } else if (Array.isArray(artifact_files) && artifact_files.length > 0) {
1282
+ let total = 0;
1283
+ artifacts = artifact_files.map((f) => {
1284
+ if (!f || typeof f.path !== "string" || typeof f.content !== "string") {
1285
+ throw new Error("Each artifact_files entry needs `path` and `content`.");
1286
+ }
1287
+ const buffer = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
1288
+ total += buffer.byteLength;
1289
+ if (total > PLUG_MAX_TOTAL_BYTES) {
1290
+ throw new Error("The upload exceeds the 100MB plug limit.");
1291
+ }
1292
+ return { path: f.path, buffer };
1293
+ });
1294
+ } else {
1295
+ throw new Error(
1296
+ ctx.edition === "web"
1297
+ ? "Provide the source via `artifact_files`."
1298
+ : "Provide the source via `path` (a local file or folder) or `artifact_files`.",
1299
+ );
1300
+ }
1301
+
1302
+ const isEdit = typeof target_entity_id === "string" && target_entity_id.length > 0;
1303
+
1304
+ // An inspiration edit content-versions the FIRST uploaded artifact — when a
1305
+ // multi-file folder rides a re-plug, put the primary entry first so the edit
1306
+ // swaps the right file (index.html > any .html > everything else).
1307
+ if (isEdit && artifacts.length > 1) {
1308
+ const prio = (a) =>
1309
+ a.path === "index.html" ? 0 : /\.html?$/i.test(a.path) ? 1 : a.path.startsWith(".") ? 3 : 2;
1310
+ artifacts = artifacts
1311
+ .map((a, i) => ({ a, i }))
1312
+ .sort((x, y) => prio(x.a) - prio(y.a) || x.i - y.i)
1313
+ .map((x) => x.a);
1314
+ }
1315
+
1316
+ // ── Auth wire selection ─────────────────────────────────────────────────────
1317
+ const authToken = anon === true ? null : await ctx.getToken();
1318
+ let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
1319
+ if (isEdit && !ownerToken) {
1320
+ // Recover the owner token from session state when re-plugging the drop this
1321
+ // session made anonymously.
1322
+ if (ctx.state.lastDrop?.entity_id === target_entity_id && ctx.state.lastDrop.owner_token) {
1323
+ ownerToken = ctx.state.lastDrop.owner_token;
1324
+ } else if (
1325
+ ctx.state.lastAnonClaim?.entity_id === target_entity_id &&
1326
+ ctx.state.lastAnonClaim.token
1327
+ ) {
1328
+ ownerToken = ctx.state.lastAnonClaim.token;
1329
+ }
1330
+ }
1331
+ // An anon-minted (Guest-Grid) drop is edited via the owner-token wire even
1332
+ // when signed in — the entity is not in the caller's grid, so an authed edit
1333
+ // of it would 404. Otherwise an edit needs the authed wire.
1334
+ const useAnonWire = isEdit ? Boolean(ownerToken) : !authToken;
1335
+ if (isEdit && !ownerToken && !authToken) {
1336
+ throw new Error(
1337
+ "Re-plugging needs authorization: sign in (gridctl_login) for an entity in your grid, or pass the " +
1338
+ "owner_token that came back when the drop was created anonymously.",
1339
+ );
1340
+ }
1341
+
1342
+ const headers = {};
1343
+ if (!useAnonWire && authToken) {
1344
+ headers["Authorization"] = `Bearer ${authToken}`;
1345
+ // On create, `grid` picks where the entity lands. On re-plug the entity's
1346
+ // home grid is authoritative (it never moves) — but the API still resolves
1347
+ // the caller's membership from this header and requires it to MATCH the
1348
+ // entity's grid, so pass `grid` here too when the target lives outside the
1349
+ // active grid.
1350
+ const orgSlug = grid || (await ctx.getActiveOrg());
1351
+ if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
1352
+ }
1353
+ if (useAnonWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
1354
+ headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
1355
+ headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
1356
+ }
1357
+ if (useAnonWire && ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
1358
+
1359
+ // ── Wire assembly ───────────────────────────────────────────────────────────
1360
+ const form = new FormData();
1361
+ for (const a of artifacts) {
1362
+ form.append("artifact", new Blob([a.buffer], { type: "application/octet-stream" }), a.path);
1363
+ }
1364
+ if (isEdit) {
1365
+ form.append("target_entity_id", target_entity_id);
1366
+ if (useAnonWire) {
1367
+ form.append("owner_token", ownerToken);
1368
+ } else {
1369
+ // The authed update path requires a `cloudgrid.yaml` part
1370
+ // (materializePlugTarball); an inspiration edit ignores its content.
1371
+ form.append(
1372
+ "cloudgrid.yaml",
1373
+ new Blob([cloudgrid_yaml || ""], { type: "text/plain" }),
1374
+ "cloudgrid.yaml",
1375
+ );
1376
+ }
1377
+ } else if (cloudgrid_yaml) {
1378
+ // On CREATE the manifest rides as a regular artifact file (the create wire
1379
+ // filters a part FIELD-named `cloudgrid.yaml`, but a file named
1380
+ // cloudgrid.yaml lands in the detected source tree).
1381
+ form.append(
1382
+ "artifact",
1383
+ new Blob([cloudgrid_yaml], { type: "text/plain" }),
1384
+ "cloudgrid.yaml",
1385
+ );
1386
+ }
1387
+ if (hints?.kind) {
1388
+ // `kind_hint` is what the create orchestrator reads; `hints_kind` is the
1389
+ // route's structured field on the update path. Send both — each path
1390
+ // ignores the other's.
1391
+ form.append("kind_hint", hints.kind);
1392
+ form.append("hints_kind", hints.kind);
1393
+ }
1394
+ if (hints?.yaml) form.append("hints_yaml", hints.yaml);
1395
+
1396
+ let res;
1397
+ try {
1398
+ res = await fetch(`${API_BASE}/api/v2/plug`, { method: "POST", headers, body: form });
1399
+ } catch (err) {
1400
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1401
+ }
1402
+ const raw = await res.text();
1403
+ let data = null;
1404
+ try {
1405
+ data = JSON.parse(raw);
1406
+ } catch {
1407
+ /* handled below */
1408
+ }
1409
+ if (!res.ok) {
1410
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
1411
+ throw new Error(plugErrorMessage(res.status, data?.error?.code, msg));
1412
+ }
1413
+
1414
+ // Anon-session cookie continuity (mirrors runDrop).
1415
+ const setCookies = res.headers.getSetCookie
1416
+ ? res.headers.getSetCookie()
1417
+ : [res.headers.get("set-cookie")].filter(Boolean);
1418
+ const anonCookie = (setCookies || [])
1419
+ .map((c) => (c || "").split(";")[0])
1420
+ .find((c) => c.startsWith("cg_anon_session="));
1421
+ if (anonCookie) ctx.state.anonCookie = anonCookie;
1422
+
1423
+ const url = resolvePlugUrl(data);
1424
+ let freshOwnerToken = typeof data.owner_token === "string" && data.owner_token.length > 0
1425
+ ? data.owner_token
1426
+ : null;
1427
+ if (!freshOwnerToken && data.claim_url) {
1428
+ try {
1429
+ freshOwnerToken = new URL(data.claim_url).searchParams.get("token");
1430
+ } catch {
1431
+ freshOwnerToken = null;
1432
+ }
1433
+ }
1434
+
1435
+ // Session continuity — the same state runDrop keeps.
1436
+ if (data.entity_id || url) {
1437
+ ctx.state.lastDrop = {
1438
+ entity_id: data.entity_id ?? null,
1439
+ url: url ?? null,
1440
+ owner_token: useAnonWire ? (freshOwnerToken ?? ownerToken ?? null) : null,
1441
+ };
1442
+ }
1443
+ if (useAnonWire && (data.claim_url || freshOwnerToken)) {
1444
+ ctx.state.lastAnonClaim = {
1445
+ token: freshOwnerToken,
1446
+ entity_id: data.entity_id ?? null,
1447
+ url,
1448
+ };
1449
+ } else if (!useAnonWire) {
1450
+ ctx.state.lastAnonClaim = null;
1451
+ }
1452
+
1453
+ const structured = {
1454
+ ...(data.entity_id ? { entity_id: data.entity_id } : {}),
1455
+ ...(data.slug ? { slug: data.slug } : {}),
1456
+ grid: data.grid ?? null,
1457
+ ...(url ? { url } : {}),
1458
+ ...(data.poll_url ? { poll_url: data.poll_url } : {}),
1459
+ status: data.status ?? (isEdit ? "updated" : "created"),
1460
+ ...(data.claim_url ? { claim_url: data.claim_url } : {}),
1461
+ ...(data.claim_message ? { claim_message: data.claim_message } : {}),
1462
+ // Spec v2 omits owner_token from the output block — a spec bug (the anon
1463
+ // wire cannot re-plug without it). Included deliberately; flagged upstream.
1464
+ ...(freshOwnerToken ? { owner_token: freshOwnerToken } : {}),
1465
+ };
1466
+
1467
+ const lines = [];
1468
+ lines.push(isEdit ? `Updated in place: ${url}` : `Live: ${url}`);
1469
+ if (data.status === "building" && data.poll_url) {
1470
+ lines.push(`Build dispatched — poll ${data.poll_url} (trace ${data.trace_id ?? "n/a"}).`);
1471
+ }
1472
+ if (data.entity_id) {
1473
+ lines.push(
1474
+ `Re-plug handle: entity_id=${data.entity_id} — persist it (with the url) and pass it back as target_entity_id to update this entity later.`,
1475
+ );
1476
+ }
1477
+ if (data.claim_message) lines.push(data.claim_message);
1478
+ if (isEdit && useAnonWire) {
1479
+ lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
1480
+ }
1481
+ return { text: lines.join("\n"), structured };
1482
+ }
1483
+
1484
+ // ── gridctl_fork / gridctl_download — direct-API verbs (spec v2 §5–6) ────────
1485
+
1486
+ async function authedApiCall(ctx, { method, pathName, body, verb }) {
1487
+ const token = await ctx.getToken();
1488
+ if (!token) {
1489
+ throw new Error(`${verb} requires sign-in. Run gridctl_login first.`);
1490
+ }
1491
+ const headers = { Authorization: `Bearer ${token}` };
1492
+ const orgSlug = await ctx.getActiveOrg();
1493
+ if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
1494
+ if (body !== undefined) headers["Content-Type"] = "application/json";
1495
+ let res;
1496
+ try {
1497
+ res = await fetch(`${API_BASE}${pathName}`, {
1498
+ method,
1499
+ headers,
1500
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
1501
+ });
1502
+ } catch (err) {
1503
+ throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1504
+ }
1505
+ const raw = await res.text();
1506
+ let data = null;
1507
+ try {
1508
+ data = JSON.parse(raw);
1509
+ } catch {
1510
+ /* handled below */
1511
+ }
1512
+ if (!res.ok) {
1513
+ const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
1514
+ const code = data?.error?.code ? ` ${data.error.code}` : "";
1515
+ throw new Error(`${verb} failed (HTTP ${res.status}${code}): ${msg}`);
1516
+ }
1517
+ return data;
1518
+ }
1519
+
1520
+ // Fork: start a NEW entity from an existing runtime, copy-on-write with lineage.
1521
+ async function runFork(ctx, { id, into_org_slug, name, source_version_id }) {
1522
+ const data = await authedApiCall(ctx, {
1523
+ method: "POST",
1524
+ pathName: `/api/v2/runtimes/${encodeURIComponent(id)}/fork`,
1525
+ body: {
1526
+ ...(into_org_slug ? { into_org_slug } : {}),
1527
+ ...(name ? { name } : {}),
1528
+ ...(source_version_id ? { source_version_id } : {}),
1529
+ },
1530
+ verb: "Fork",
1531
+ });
1532
+ const gridSlug = data?.grid_slug ?? data?.org?.slug ?? null;
1533
+ const lines = [
1534
+ `Forked: ${data?.name ?? id} (entity_id=${data?.entity_id ?? "?"})${gridSlug ? ` in grid ${gridSlug}` : ""}`,
1535
+ `Lineage: forked_from=${data?.forked_from ?? "?"}${data?.forked_from_version_id ? ` @ ${data.forked_from_version_id}` : ""}`,
1536
+ ];
1537
+ return {
1538
+ text: lines.join("\n"),
1539
+ structured: {
1540
+ entity_id: data?.entity_id ?? null,
1541
+ name: data?.name ?? null,
1542
+ kind: data?.kind ?? null,
1543
+ grid_slug: gridSlug,
1544
+ forked_from: data?.forked_from ?? null,
1545
+ forked_from_version_id: data?.forked_from_version_id ?? null,
1546
+ current_version_id: data?.current_version_id ?? null,
1547
+ },
1548
+ };
1549
+ }
1550
+
1551
+ // Download: signed, time-limited (15-minute) source-bundle URLs. No entity is
1552
+ // created and no registry state changes.
1553
+ async function runDownload(ctx, { id, version }) {
1554
+ const qs = version ? `?version=${encodeURIComponent(version)}` : "";
1555
+ const data = await authedApiCall(ctx, {
1556
+ method: "GET",
1557
+ pathName: `/api/v2/runtimes/${encodeURIComponent(id)}/source${qs}`,
1558
+ verb: "Download",
1559
+ });
1560
+ const services = data?.services && typeof data.services === "object" ? data.services : {};
1561
+ const lines = [`Source bundle URLs for ${data?.name ?? id} (valid ~15 minutes):`];
1562
+ for (const [svc, u] of Object.entries(services)) lines.push(` ${svc}: ${u}`);
1563
+ if (Object.keys(services).length === 0) lines.push(" (no services returned)");
1564
+ return {
1565
+ text: lines.join("\n"),
1566
+ structured: {
1567
+ entity_id: data?.entity_id ?? null,
1568
+ name: data?.name ?? null,
1569
+ services,
1570
+ domain: data?.domain ?? null,
1571
+ },
1572
+ };
1573
+ }
1574
+
760
1575
  // Change an inspiration's visibility. Authed, direct API — works on the hosted
761
1576
  // edition where the CLI-wrapping share tool is unavailable. Defaults to the drop
762
1577
  // made in this session, so "make it private" needs no ids.
@@ -867,6 +1682,11 @@ export function registerTools(server, ctx) {
867
1682
  // Drop — both editions, but the input schema is edition-aware: the web
868
1683
  // edition removes `path` (the cloud server cannot read local files) and
869
1684
  // strengthens `html` so the model always pastes the full document inline.
1685
+ const dropReplugParams = {
1686
+ 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."),
1687
+ 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."),
1688
+ 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."),
1689
+ };
870
1690
  const dropInputSchema = ctx.edition === "web"
871
1691
  ? {
872
1692
  html: z.string().optional().describe(
@@ -877,7 +1697,7 @@ export function registerTools(server, ctx) {
877
1697
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
878
1698
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
879
1699
  org: z.string().optional().describe("Leave unset; the tool will ask the user which org to publish into. Only set this after the user picks from the list the tool returns."),
880
- fresh: z.boolean().optional().describe("Force a new drop even if you already dropped in this session (default: update in place)."),
1700
+ ...dropReplugParams,
881
1701
  }
882
1702
  : {
883
1703
  html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
@@ -885,17 +1705,19 @@ export function registerTools(server, ctx) {
885
1705
  filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
886
1706
  anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
887
1707
  org: z.string().optional().describe("Leave unset; the tool will ask the user which org to publish into. Only set this after the user picks from the list the tool returns."),
888
- fresh: z.boolean().optional().describe("Force a new drop even if you already dropped in this session (default: update in place)."),
1708
+ ...dropReplugParams,
889
1709
  };
890
1710
 
891
1711
  reg(
892
1712
  "gridctl_drop",
893
1713
  {
894
- 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 existing drop in place — same link, new version; pass fresh: true to force a new one. If signed in, it publishes into the user's org as an owned inspiration (30-day expiry); if not, it drops anonymously (7-day expiry, claimable later). Calls the API directly.",
1714
+ 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. Calls POST /api/v2/plug directly.",
895
1715
  inputSchema: dropInputSchema,
896
1716
  outputSchema: {
897
- url: z.string().optional().describe("The public URL of the drop."),
1717
+ url: z.string().optional().describe("The public URL of the drop (stable across re-drops of the same entity)."),
898
1718
  status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
1719
+ entity_id: z.string().optional().describe("The entity's durable id — pass back as entity_id to update it in place later."),
1720
+ 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."),
899
1721
  owned_by: z.string().optional().describe("Ownership class, e.g. 'authenticated'."),
900
1722
  expires_at: z.string().optional().describe("Expiry timestamp, if any."),
901
1723
  console_url: z.string().optional().describe("URL to manage all apps in the grid."),
@@ -1016,9 +1838,9 @@ export function registerTools(server, ctx) {
1016
1838
  reg(
1017
1839
  "gridctl_claim",
1018
1840
  {
1019
- description: "Claim an anonymous drop into the signed-in account, so it becomes owned and stops expiring in 7 days. Use after the user signs in to keep something they dropped anonymously. The public URL does not change. Requires sign-in (gridctl_login). Calls the API directly.",
1841
+ description: "Claim an anonymous drop into the signed-in account, so it becomes owned and stops expiring on the anonymous schedule. Use after the user signs in to keep something they dropped anonymously. The public URL does not change. The claim token IS the drop's owner_token (one bearer capability for both edit and claim — anonymous edits refresh it, so always use the newest). Requires sign-in (gridctl_login). Calls the API directly.",
1020
1842
  inputSchema: {
1021
- claim_token: z.string().optional().describe("The claim token from an anonymous drop."),
1843
+ claim_token: z.string().optional().describe("The claim/owner token from an anonymous drop (owner_token in the drop result; also embedded in claim_url)."),
1022
1844
  claim_url: z.string().optional().describe("The claim_url from an anonymous drop; the token is read from it."),
1023
1845
  entity_id: z.string().optional().describe("The entity id of the anonymous drop to claim. Defaults to this session's last anonymous drop."),
1024
1846
  },
@@ -1037,6 +1859,157 @@ export function registerTools(server, ctx) {
1037
1859
  },
1038
1860
  );
1039
1861
 
1862
+ // ── gridctl_plug — the unified create/re-plug verb (spec v2 §3) ────────────
1863
+ // Direct-API on BOTH editions (POST /api/v2/plug). Replaces the former
1864
+ // CLI-wrapping gridctl_plug: create and re-plug are one verb, keyed by
1865
+ // target_entity_id, and work identically on the hosted transport.
1866
+ const plugInputSchema = {
1867
+ ...(ctx.edition === "web"
1868
+ ? {}
1869
+ : {
1870
+ path: z.string().optional().describe(
1871
+ "Local edition: path to the entity folder (or a single file) to upload. A folder is read " +
1872
+ "recursively, honoring .gitignore/.cloudgridignore (plus .git/node_modules always skipped). " +
1873
+ "Mutually exclusive with artifact_files.",
1874
+ ),
1875
+ }),
1876
+ artifact_files: z.array(z.object({
1877
+ path: z.string().describe("Repo-relative path, e.g. index.html or services/web/index.js."),
1878
+ content: z.string().describe("File content. Base64 when encoding is base64, otherwise UTF-8 text."),
1879
+ encoding: z.enum(["utf8", "base64"]).optional().describe("Content encoding. Default utf8."),
1880
+ })).optional().describe(
1881
+ "The source inline, one entry per file — for hosted/no-filesystem transports (an HTML deck, a one-file app)." +
1882
+ (ctx.edition === "web" ? "" : " Prefer `path` on the local edition."),
1883
+ ),
1884
+ cloudgrid_yaml: z.string().optional().describe(
1885
+ "Inline cloudgrid.yaml (the entity manifest). Optional — server auto-detection applies when omitted. " +
1886
+ "On re-plug, a name: change is a warning only; it never renames the entity or moves the URL.",
1887
+ ),
1888
+ target_entity_id: z.string().optional().describe(
1889
+ "Present → RE-PLUG: update this exact entity in place (same entity_id, slug, URL, deploy history). " +
1890
+ "Absent → CREATE a new entity. This is the durable handle a previous plug returned — persist it. " +
1891
+ "Re-plugging an anonymously-created drop needs its owner_token instead of sign-in.",
1892
+ ),
1893
+ grid: z.string().optional().describe(
1894
+ "On create: the grid slug to plug into (omit to use the caller's active grid). On re-plug the entity " +
1895
+ "never moves grids, but pass its home grid here when it differs from your active grid (the API " +
1896
+ "checks your membership in the entity's grid). Anonymous → always the Guest grid.",
1897
+ ),
1898
+ hints: z.object({
1899
+ kind: z.enum(["inspiration", "app", "agent"]).optional().describe("Force the detected kind; omit to let the server auto-detect."),
1900
+ yaml: z.string().optional().describe("An inline cloudgrid.yaml override used as a detection hint."),
1901
+ }).optional().describe("Classification hints for the CREATE path (not entity targeting — that's target_entity_id)."),
1902
+ anon: z.boolean().optional().describe(
1903
+ "Create an anonymous Guest-Grid drop (no auth). The response carries claim_url + owner_token; persist " +
1904
+ "entity_id + owner_token as the stateless re-plug/claim handle.",
1905
+ ),
1906
+ owner_token: z.string().optional().describe(
1907
+ "The owner token of an anonymously-created drop — authorizes an anonymous re-plug (with " +
1908
+ "target_entity_id). Re-minted on every anonymous edit; always persist the newest one from the result.",
1909
+ ),
1910
+ };
1911
+
1912
+ reg(
1913
+ "gridctl_plug",
1914
+ {
1915
+ description:
1916
+ "Surface a creation onto the grid — the unified create/re-plug verb (POST /api/v2/plug). " +
1917
+ "No target_entity_id → CREATE a new entity (inspiration/app/agent, auto-detected or hinted); " +
1918
+ "with target_entity_id → RE-PLUG: update the SAME entity in place — same entity_id, same URL, same " +
1919
+ "deploy history, expiry reset. The returned entity_id + url are the durable re-plug handle; persist " +
1920
+ "them (plus owner_token for anonymous drops) to update the entity in later sessions. " +
1921
+ (ctx.edition === "web"
1922
+ ? "Pass the source inline via artifact_files."
1923
+ : "Pass the source as a local `path` (folder or file) or inline via artifact_files.") +
1924
+ " Note: in-place re-plug currently supports inspirations (HTML/static drops); to rebuild a deployed " +
1925
+ "app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder.",
1926
+ inputSchema: plugInputSchema,
1927
+ outputSchema: {
1928
+ entity_id: z.string().optional().describe("Globally unique — pass back as target_entity_id to re-plug."),
1929
+ slug: z.string().optional().describe("Grid-scoped slug."),
1930
+ grid: z.string().nullable().optional().describe("Home grid slug; null for an anonymous Guest-Grid drop."),
1931
+ url: z.string().optional().describe("Canonical serving URL (stable across re-plugs; server-composed, flat-arch-aware)."),
1932
+ poll_url: z.string().optional().describe("Deploy status path while building (runtimes only)."),
1933
+ status: z.string().optional().describe("live | building | created | updated …"),
1934
+ claim_url: z.string().optional().describe("Anon create only: sign-in link to claim ownership."),
1935
+ claim_message: z.string().optional().describe("Anon create only: the claim nudge to relay."),
1936
+ owner_token: z.string().optional().describe("Anonymous drops: the bearer owner token (re-plug + claim). Re-minted on every anonymous edit — persist the newest."),
1937
+ },
1938
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1939
+ },
1940
+ async (input) => {
1941
+ try {
1942
+ return okResult(await runPlug(ctx, input || {}));
1943
+ } catch (err) {
1944
+ return fail(err.message);
1945
+ }
1946
+ },
1947
+ );
1948
+
1949
+ // ── gridctl_fork / gridctl_download — direct-API verbs (spec v2 §5–6) ──────
1950
+ reg(
1951
+ "gridctl_fork",
1952
+ {
1953
+ description:
1954
+ "Start a NEW entity from an existing runtime (copy-on-write, lineage recorded). Lands in the " +
1955
+ "source's home grid by default; cross-grid only for system templates or forkable:'public' sources. " +
1956
+ "Requires sign-in. Calls POST /api/v2/runtimes/:id/fork directly.",
1957
+ inputSchema: {
1958
+ id: z.string().describe("The source runtime: a canonical UUID or <grid-slug>/<entity-slug>."),
1959
+ into_org_slug: z.string().optional().describe("Destination grid slug. Required only when you belong to more than one grid."),
1960
+ name: z.string().optional().describe("Slug for the new entity. Omit to derive one from the source."),
1961
+ source_version_id: z.string().optional().describe("Fork an older version instead of HEAD, e.g. v_a1b2c3d."),
1962
+ },
1963
+ outputSchema: {
1964
+ entity_id: z.string().nullable().describe("The new entity's id."),
1965
+ name: z.string().nullable().describe("The new entity's slug."),
1966
+ kind: z.string().nullable().describe("app | agent | inspiration."),
1967
+ grid_slug: z.string().nullable().describe("The grid the fork landed in."),
1968
+ forked_from: z.string().nullable().describe("Source entity_id."),
1969
+ forked_from_version_id: z.string().nullable().describe("Source version, when a specific one was forked."),
1970
+ current_version_id: z.string().nullable().describe("The fork's current version id."),
1971
+ },
1972
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1973
+ },
1974
+ async (input) => {
1975
+ try {
1976
+ if (!input?.id) return fail("`id` is required (a canonical UUID or <grid-slug>/<entity-slug>).");
1977
+ return okResult(await runFork(ctx, input));
1978
+ } catch (err) {
1979
+ return fail(err.message);
1980
+ }
1981
+ },
1982
+ );
1983
+
1984
+ reg(
1985
+ "gridctl_download",
1986
+ {
1987
+ description:
1988
+ "Fetch the source bundle last deployed for a runtime: one signed, time-limited (15-minute) read URL " +
1989
+ "per service tarball. No entity is created and no registry state changes. Requires sign-in. Calls " +
1990
+ "GET /api/v2/runtimes/:id/source directly.",
1991
+ inputSchema: {
1992
+ id: z.string().describe("The runtime to download: a canonical UUID or <grid-slug>/<entity-slug>."),
1993
+ version: z.string().optional().describe("Download an older version's bundle instead of HEAD, e.g. v_a1b2c3d."),
1994
+ },
1995
+ outputSchema: {
1996
+ entity_id: z.string().nullable().describe("The runtime's entity id."),
1997
+ name: z.string().nullable().describe("The runtime's slug."),
1998
+ services: z.record(z.string()).describe("Service name → signed read URL (valid ~15 minutes)."),
1999
+ domain: z.string().nullable().optional().describe("The runtime's domain, if any."),
2000
+ },
2001
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
2002
+ },
2003
+ async (input) => {
2004
+ try {
2005
+ if (!input?.id) return fail("`id` is required (a canonical UUID or <grid-slug>/<entity-slug>).");
2006
+ return okResult(await runDownload(ctx, input));
2007
+ } catch (err) {
2008
+ return fail(err.message);
2009
+ }
2010
+ },
2011
+ );
2012
+
1040
2013
  // Login — both editions. Local opens a browser and saves to the credentials
1041
2014
  // file; web returns the URL and saves to the session.
1042
2015
  reg(
@@ -1310,25 +2283,9 @@ export function registerTools(server, ctx) {
1310
2283
  }, { cwdParam: true }),
1311
2284
  );
1312
2285
 
1313
- regTool(
1314
- "gridctl_plug",
1315
- "Build and deploy a directory or URL. Prints the live URL. Wraps `cloudgrid plug`.",
1316
- {
1317
- target: z.string().optional().describe("Path or URL. Omit to deploy the entity linked to the current directory."),
1318
- org: z.string().optional().describe("Pick or override the org."),
1319
- no_deploy: z.boolean().optional().describe("Register the entity but do not build or deploy."),
1320
- cwd: z.string().optional().describe("Working directory. The CLI runs in this directory. Defaults to the MCP server's working directory."),
1321
- },
1322
- { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1323
- cliTool(({ target, org, no_deploy }) => {
1324
- const args = ["plug"];
1325
- if (target) args.push(target);
1326
- if (org) args.push("--org", org);
1327
- if (no_deploy) args.push("--no-deploy");
1328
- args.push("--auto", "--no-clipboard", "--no-notify");
1329
- return args;
1330
- }, { cwdParam: true }),
1331
- );
2286
+ // NOTE: gridctl_plug is no longer CLI-wrapping — the unified direct-API verb
2287
+ // (create + re-plug via POST /api/v2/plug) is registered above for BOTH
2288
+ // editions, per spec v2 §3.
1332
2289
 
1333
2290
  regTool(
1334
2291
  "gridctl_logs",