nylorun 0.9.0-beta → 0.11.1-beta

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.
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, rm } from "node:fs/promises";
3
3
  import { basename, resolve } from "node:path";
4
- import { compareVersions, PROTOCOL_HEADER, PROTOCOL_VERSION, } from "@nylorun/core/compatibility";
4
+ import { compareVersions } from "@nylorun/core/compatibility";
5
5
  import { CliError } from "../errors.js";
6
6
  import { credentialsPath, readCredentialsFile, readProjectCredentials, readProjectLink, writeProjectCredentials, writeProjectLink, } from "../project/link.js";
7
7
  import { findProjectRoot } from "../project/root.js";
@@ -30,10 +30,10 @@ const CORE_SERVICES = ["postgres", "restate", "s2-lite", "rustfs", "gateway", "r
30
30
  function coreServices(harness) {
31
31
  return CORE_SERVICES.filter((service) => service !== "harness" || harness === "remote");
32
32
  }
33
- export const stackUsage = ` up|start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset] [--restate-ui]
33
+ export const stackUsage = ` up|start [--tenant <name>] [--no-link] [--no-studio] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset] [--restate-ui]
34
34
  start the project's Tenant, creating it and the Project link (.nylorun/link.json,
35
- credentials.json) on the first run; print the Runtime and Studio URLs and open Studio
36
- signed in (in a terminal). --tenant attaches to (or creates) a named Tenant; --no-link
35
+ credentials.json) on the first run; print the Runtime and Studio URLs (it opens no
36
+ browser). --tenant attaches to (or creates) a named Tenant; --no-link
37
37
  starts the default Tenant (or --tenant's) without linking the current directory;
38
38
  --restate-ui (or NYLORUN_RESTATE_UI=1) publishes Restate's UI on loopback for debugging
39
39
  down|stop [--tenant <name> | --all] stop the Tenant's containers (--all: every Tenant's); keep volumes
@@ -147,6 +147,13 @@ function composeArgs(ctx, ...args) {
147
147
  ...args,
148
148
  ];
149
149
  }
150
+ /** `nylorun-operate` in the Tenant's runtime container (`docker compose exec`), for its keys. */
151
+ function operateEndpoint(ctx) {
152
+ return {
153
+ fetch: ctx.deps.fetch,
154
+ operate: (args) => ctx.deps.docker.run(composeArgs(ctx, "exec", "-T", "runtime", "nylorun-operate", ...args)),
155
+ };
156
+ }
150
157
  function requireStackFiles(ctx) {
151
158
  if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
152
159
  throw new CliError(`No Tenant ${ctx.name} under ${ctx.paths.root}. Run "nylorun start" first.`, 3);
@@ -221,34 +228,21 @@ async function waitForHealth(ctx, runtimeUrl, hostId) {
221
228
  await sleep(ctx.deps.pollMs ?? 500);
222
229
  }
223
230
  }
224
- /** `/v1/admin/status` on the operator listener; undefined when it does not answer. */
225
- async function fetchAdminStatus(deps, adminUrl, adminKey) {
226
- if (!adminKey)
227
- return undefined;
231
+ /** The Runtime's `/ready` (503 while not ready, same body); undefined when it does not answer. */
232
+ async function fetchReady(deps, runtimeUrl) {
228
233
  try {
229
- const response = await deps.fetch(`${adminUrl}/v1/admin/status`, {
230
- headers: {
231
- authorization: `Bearer ${adminKey}`,
232
- [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
233
- accept: "application/json",
234
- },
234
+ const response = await deps.fetch(`${runtimeUrl}/ready`, {
235
+ headers: { accept: "application/json" },
235
236
  signal: AbortSignal.timeout(3000),
236
237
  redirect: "error",
237
238
  });
238
- if (!response.ok)
239
- return undefined;
240
239
  const body = (await response.json());
241
- const tenant = body.tenant;
242
- if (!tenant || (tenant.state !== "open" && tenant.state !== "unavailable"))
240
+ if (body.status !== "ready" && body.status !== "not_ready")
243
241
  return undefined;
244
- const harness = body.aggregate?.harness;
242
+ const harness = body.harness;
245
243
  return {
246
- tenant: {
247
- id: typeof tenant.id === "string" ? tenant.id : null,
248
- name: typeof tenant.name === "string" ? tenant.name : null,
249
- state: tenant.state,
250
- ...(tenant.cause ? { cause: tenant.cause } : {}),
251
- },
244
+ ready: body.status === "ready",
245
+ tenant: body.checks?.tenant === true,
252
246
  ...((harness?.mode === "remote" || harness?.mode === "in-process") &&
253
247
  typeof harness.connected === "number"
254
248
  ? { harness: { mode: harness.mode, connected: harness.connected } }
@@ -259,32 +253,59 @@ async function fetchAdminStatus(deps, adminUrl, adminKey) {
259
253
  return undefined;
260
254
  }
261
255
  }
262
- /** `/v1/admin/status`'s Tenant on the operator listener; undefined when it does not answer. */
263
- async function fetchTenant(deps, adminUrl, adminKey) {
264
- return (await fetchAdminStatus(deps, adminUrl, adminKey))?.tenant;
256
+ /**
257
+ * `nylorun-operate status --json` in the runtime container (exit 0 open, 2 not open): the
258
+ * Tenant's id and name, and why it cannot open. Undefined when it does not answer.
259
+ */
260
+ async function operateStatus(ctx) {
261
+ const result = await operateEndpoint(ctx).operate(["status", "--json"]);
262
+ if (result.code !== 0 && result.code !== 2)
263
+ return undefined;
264
+ let tenant;
265
+ try {
266
+ tenant = JSON.parse(result.stdout).tenant;
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ if (!tenant || (tenant.state !== "open" && tenant.state !== "unavailable"))
272
+ return undefined;
273
+ return {
274
+ id: typeof tenant.id === "string" ? tenant.id : null,
275
+ name: typeof tenant.name === "string" ? tenant.name : null,
276
+ state: tenant.state,
277
+ ...(typeof tenant.cause === "string" ? { cause: tenant.cause } : {}),
278
+ ...(typeof tenant.message === "string" ? { message: tenant.message } : {}),
279
+ };
265
280
  }
266
- /** Why the Tenant is unavailable, with the repair the Runtime names. */
281
+ /** Why the Tenant is unavailable, as `nylorun-operate status` names it. */
267
282
  function tenantCauseMessage(ctx, tenant) {
268
- const cause = tenant.cause;
269
- const hint = cause.code === "schema-too-new"
283
+ const hint = tenant.cause === "schema-too-new"
270
284
  ? ` This nylorun pins Runtime ${ctx.deps.runtimeVersion}, older than the Tenant's database: update nylorun (npx nylorun@latest start).`
271
285
  : "";
272
- return `Tenant ${ctx.name} is unavailable (${cause.code}): ${cause.message} ${cause.repair}${hint} See "nylorun logs runtime".`;
286
+ const message = tenant.message ? `: ${tenant.message.replace(/\.?$/, ".")}` : ".";
287
+ return `Tenant ${ctx.name} is unavailable (${tenant.cause})${message}${hint} See "nylorun logs runtime".`;
273
288
  }
274
289
  /**
275
- * Wait until the Tenant is open: the Runtime creates it on the first start and opens
276
- * it on later ones. A Tenant that could not be opened has a cause; report it.
290
+ * Wait until the Tenant is open (`/ready`'s `tenant` check): the Runtime creates it on the
291
+ * first start and opens it on later ones. Then `nylorun-operate status` gives its id. While it
292
+ * is not open, `nylorun-operate status` is asked now and then: it names a cause only when the
293
+ * Tenant cannot open (`schema-too-new`, `database-layout-old`), which ends the wait.
277
294
  */
278
- async function waitForTenant(ctx, adminUrl, adminKey) {
295
+ async function waitForTenant(ctx, runtimeUrl) {
279
296
  const deadline = Date.now() + (ctx.deps.healthTimeoutMs ?? 60_000);
280
- for (;;) {
281
- const tenant = await fetchTenant(ctx.deps, adminUrl, adminKey);
282
- if (tenant?.state === "open" && tenant.id)
283
- return { ...tenant, id: tenant.id };
284
- if (tenant?.cause)
285
- throw new CliError(tenantCauseMessage(ctx, tenant), 7);
286
- if (Date.now() > deadline)
287
- throw new CliError(`Tenant ${ctx.name} did not open (${adminUrl}/v1/admin/status: ${tenant ? tenant.state : "no answer"}). See "nylorun status" and "nylorun logs runtime".`, 7);
297
+ for (let poll = 1;; poll += 1) {
298
+ const open = (await fetchReady(ctx.deps, runtimeUrl))?.tenant === true;
299
+ const late = Date.now() > deadline;
300
+ if (open || late || poll % 10 === 0) {
301
+ const tenant = await operateStatus(ctx);
302
+ if (tenant?.state === "open" && tenant.id)
303
+ return { ...tenant, id: tenant.id };
304
+ if (!open && tenant?.cause)
305
+ throw new CliError(tenantCauseMessage(ctx, tenant), 7);
306
+ if (late)
307
+ throw new CliError(`Tenant ${ctx.name} did not open (${runtimeUrl}/ready: ${open ? "open, but nylorun-operate status did not answer" : "not open"})${tenant?.message ? `: ${tenant.message.replace(/\.$/, "")}` : ""}. See "nylorun status" and "nylorun logs runtime".`, 7);
308
+ }
288
309
  await sleep(ctx.deps.pollMs ?? 500);
289
310
  }
290
311
  }
@@ -353,7 +374,6 @@ async function tenantPorts(base, except) {
353
374
  const persisted = await readStackEnv(paths);
354
375
  for (const port of [
355
376
  persisted?.runtimePort,
356
- persisted?.adminPort,
357
377
  persisted?.studioPort,
358
378
  persisted?.restatePort,
359
379
  ])
@@ -408,14 +428,13 @@ async function bringUp(ctx, options) {
408
428
  if (prepared.firstRun)
409
429
  deps.err(`Created Tenant ${ctx.name} under ${ctx.paths.root} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
410
430
  const runtimeUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.runtimePort}`;
411
- const adminUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.adminPort}`;
412
431
  const up = await deps.docker.stream(
413
432
  // --remove-orphans: a service this start's file no longer has (the harness after a rollback
414
433
  // to NYLORUN_HARNESS=in-process, sandboxes after disable) is removed, not left running.
415
434
  composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", "--remove-orphans", ...coreServices(prepared.env.harness)));
416
435
  if (up !== 0) {
417
- // A Runtime whose Tenant cannot open fails readiness; its status names the cause.
418
- const tenant = await fetchTenant(deps, adminUrl, prepared.adminKey);
436
+ // A Runtime whose Tenant cannot open fails readiness; nylorun-operate names the cause.
437
+ const tenant = await operateStatus(ctx);
419
438
  if (tenant?.cause)
420
439
  throw new CliError(tenantCauseMessage(ctx, tenant), 7);
421
440
  throw new CliError(`docker compose up failed (exit ${up}). See "nylorun logs runtime", "nylorun logs gateway", "nylorun logs harness" and "nylorun status".`, 7);
@@ -436,7 +455,6 @@ async function bringUp(ctx, options) {
436
455
  }
437
456
  return {
438
457
  runtimeUrl,
439
- adminUrl,
440
458
  hostId: prepared.host.hostId,
441
459
  studioPort: prepared.env.studioPort,
442
460
  studioUrl: studioOrigin(prepared.env.studioPort),
@@ -468,12 +486,10 @@ async function tryStudioLogin(ctx, studioPort, adminKey) {
468
486
  deps.err(`Warning: Studio at ${origin} is not reachable for a login (${lastError}).`);
469
487
  return undefined;
470
488
  }
471
- /** Printed by `start` when it does not open Studio itself. */
489
+ /** Printed by `start`, which prints Studio's URL and opens no browser. */
472
490
  export const STUDIO_SIGN_IN_HINT = 'To sign a browser in to Studio, run "npx nylorun studio".';
473
- /** `start` opens Studio only for a developer at a terminal, outside CI. */
474
- function opensBrowser(deps, flags) {
475
- return Boolean(deps.interactive) && !deps.env.CI && !flags.booleans.has("--no-open");
476
- }
491
+ /** Printed last by `start`. */
492
+ export const HELP_HINT = 'Run "npx nylorun --help" to see what else nylorun can do.';
477
493
  /**
478
494
  * Open a Studio login in the browser, or print it when no browser starts.
479
495
  * The login URL carries a single-use token, so it is printed only as a fallback.
@@ -487,11 +503,12 @@ async function openLogin(ctx, login) {
487
503
  * `.nylorun/credentials.json`. The link is rewritten only when it is older, or the Tenant's
488
504
  * name, URL, Host or id changed; a new link seeds the Tenant from the project's `.env`.
489
505
  *
490
- * The credentials file is kept while its key still reaches the Tenant (one authenticated
491
- * read). Otherwise it gets the operator key `project` (F9 I1): the one the Host root keeps for
492
- * the projects linked to this Tenant (`project-credentials.json`), or a new one put through
493
- * the Admin API. Every checkout linked to the Tenant shares it, so linking one never rotates
494
- * another's key.
506
+ * The credentials file is kept while both its keys still reach the Tenant (one authenticated
507
+ * read each). Otherwise it gets the keys `project` (application) and `project-management`
508
+ * (management): the ones the Host root keeps for the projects linked to this Tenant
509
+ * (`project-credentials.json`), or new ones put through `nylorun-operate`. Every checkout linked
510
+ * to the Tenant shares them, so linking one never rotates another's keys. A file from before
511
+ * management keys keeps its application key and gains the management key.
495
512
  */
496
513
  async function linkProject(ctx, started, tenantId) {
497
514
  const { deps, projectDir, link } = ctx;
@@ -507,35 +524,26 @@ async function linkProject(ctx, started, tenantId) {
507
524
  hostId: started.hostId,
508
525
  tenantId,
509
526
  });
527
+ const reaches = (key) => keyAuthenticates(deps.fetch, started.runtimeUrl, key);
510
528
  const existing = await readProjectCredentials(projectDir);
511
- const admin = {
512
- fetch: deps.fetch,
513
- adminUrl: started.adminUrl,
514
- adminKey: started.adminKey,
515
- };
516
- const keyOptions = {
517
- admin,
518
- runtimeUrl: started.runtimeUrl,
519
- id: PROJECT_KEY_ID,
520
- file: ctx.paths.projectCredentials,
521
- lock: ctx.paths.keysLock,
522
- };
523
- let applicationKey;
524
- if (existing && (await keyAuthenticates(deps.fetch, started.runtimeUrl, existing.applicationKey))) {
525
- applicationKey = existing.applicationKey;
529
+ const kept = existing && (await reaches(existing.applicationKey)) ? existing : undefined;
530
+ let credentials = kept;
531
+ if (!kept?.management ||
526
532
  // A project key from before operator keys becomes the one the Host root keeps.
527
- if (existing.principalId === PROJECT_KEY_ID &&
528
- (await readCredentialsFile(ctx.paths.projectCredentials))?.applicationKey !== applicationKey)
529
- await hostKey({ ...keyOptions, adopt: existing });
530
- }
531
- else {
532
- const key = await hostKey(keyOptions);
533
- applicationKey = key.applicationKey;
534
- await writeProjectCredentials(projectDir, {
535
- applicationKey: key.applicationKey,
536
- principalId: key.principalId,
533
+ (kept.principalId === PROJECT_KEY_ID &&
534
+ (await readCredentialsFile(ctx.paths.projectCredentials))?.applicationKey !== kept.applicationKey) ||
535
+ !(await reaches(kept.management.key))) {
536
+ const keys = await hostKey({
537
+ endpoint: operateEndpoint(ctx),
538
+ runtimeUrl: started.runtimeUrl,
539
+ id: PROJECT_KEY_ID,
540
+ file: ctx.paths.projectCredentials,
541
+ lock: ctx.paths.keysLock,
542
+ ...(kept?.principalId === PROJECT_KEY_ID ? { adopt: kept } : {}),
537
543
  });
538
- if (existing && !fresh)
544
+ credentials = kept ? { ...kept, management: keys.management } : keys;
545
+ await writeProjectCredentials(projectDir, credentials);
546
+ if (existing && !kept && !fresh)
539
547
  deps.err(`Replaced the project's key, which no longer reaches Tenant ${ctx.name} (.nylorun/credentials.json).`);
540
548
  }
541
549
  if (!fresh)
@@ -545,7 +553,7 @@ async function linkProject(ctx, started, tenantId) {
545
553
  const seeded = await seedTenant({
546
554
  fetch: deps.fetch,
547
555
  runtimeUrl: started.runtimeUrl,
548
- applicationKey,
556
+ managementKey: credentials.management.key,
549
557
  projectRoot: projectDir,
550
558
  });
551
559
  const set = [...seeded.applied, ...(seeded.model ? [`model ${seeded.model}`] : [])];
@@ -556,11 +564,12 @@ async function linkProject(ctx, started, tenantId) {
556
564
  deps.err(`Warning: could not seed the Tenant from .env (${error instanceof Error ? error.message : String(error)}). Set the model in Studio or with "npx @nylorun/cli configure".`);
557
565
  }
558
566
  }
559
- const START_USAGE = "nylorun start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset] [--restate-ui]";
567
+ const START_USAGE = "nylorun start [--tenant <name>] [--no-link] [--no-studio] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset] [--restate-ui]";
560
568
  async function start(deps, args) {
561
569
  const flags = parseStackFlags(args, {
562
570
  booleans: [
563
571
  "--no-studio",
572
+ // Accepted and ignored: start no longer opens a browser.
564
573
  "--no-open",
565
574
  "--no-link",
566
575
  "--allow-downgrade",
@@ -588,7 +597,7 @@ async function start(deps, args) {
588
597
  ? { studioEmbedOrigins: { add: embedOrigins, reset: resetEmbed } }
589
598
  : {}),
590
599
  });
591
- const tenant = await waitForTenant(ctx, started.adminUrl, started.adminKey);
600
+ const tenant = await waitForTenant(ctx, started.runtimeUrl);
592
601
  if (ctx.projectDir)
593
602
  await linkProject({ ...ctx, projectDir: ctx.projectDir }, started, tenant.id);
594
603
  deps.out(`Tenant ${ctx.name} (${tenant.id})`);
@@ -597,15 +606,10 @@ async function start(deps, args) {
597
606
  deps.out(`Restate ${started.restateUrl} (UI and admin, unauthenticated; for debugging)`);
598
607
  if (started.studioStarted) {
599
608
  deps.out(`Studio ${started.studioUrl}`);
600
- if (opensBrowser(deps, flags)) {
601
- const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
602
- if (login)
603
- await openLogin(ctx, withNext(login, tenantStudioPath(tenant.id)));
604
- }
605
- else
606
- deps.err(STUDIO_SIGN_IN_HINT);
609
+ deps.err(STUDIO_SIGN_IN_HINT);
607
610
  }
608
611
  await alsoRunning(ctx);
612
+ deps.err(HELP_HINT);
609
613
  return 0;
610
614
  }
611
615
  /** After `start`: the machine's other running Tenants and their memory, on stderr. */
@@ -673,7 +677,7 @@ async function stackStatus(ctx) {
673
677
  project: ctx.project,
674
678
  home: ctx.paths.root,
675
679
  state: "absent",
676
- runtime: { healthy: false },
680
+ runtime: { healthy: false, ready: false },
677
681
  studio: { state: "absent" },
678
682
  restate: { published: false },
679
683
  gateway: { state: "absent", healthy: false },
@@ -691,14 +695,11 @@ async function stackStatus(ctx) {
691
695
  const health = runtimeUrl ? await fetchHealth(ctx.deps, runtimeUrl) : undefined;
692
696
  const host = await readHostConfig(ctx.paths);
693
697
  const healthy = health?.status === "ok" && (host === undefined || health.hostId === host.hostId);
694
- // The Admin API answers on the operator port; an older Host root has only the Runtime port.
695
- const adminUrl = persisted.adminPort
696
- ? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
697
- : runtimeUrl;
698
- const answered = healthy && adminUrl
699
- ? await fetchAdminStatus(ctx.deps, adminUrl, await readAdminKey(ctx.paths))
698
+ const ready = healthy && runtimeUrl ? await fetchReady(ctx.deps, runtimeUrl) : undefined;
699
+ // nylorun-operate answers while the container runs, also when the Tenant cannot open.
700
+ const tenant = services.some((s) => s.service === "runtime" && s.state === "running")
701
+ ? await operateStatus(ctx)
700
702
  : undefined;
701
- const tenant = answered?.tenant;
702
703
  const studio = services.find((s) => s.service === "studio");
703
704
  const gateway = services.find((s) => s.service === "gateway");
704
705
  const harness = services.find((s) => s.service === "harness");
@@ -708,8 +709,8 @@ async function stackStatus(ctx) {
708
709
  state: services.some((s) => s.state === "running") ? "running" : "stopped",
709
710
  runtime: {
710
711
  ...(runtimeUrl ? { url: runtimeUrl } : {}),
711
- ...(persisted.adminPort && adminUrl ? { adminUrl } : {}),
712
712
  healthy,
713
+ ready: ready?.ready ?? false,
713
714
  ...(health?.version ? { version: health.version } : {}),
714
715
  ...(health?.hostId ? { hostId: health.hostId } : {}),
715
716
  },
@@ -729,10 +730,10 @@ async function stackStatus(ctx) {
729
730
  healthy: isUp(services, "gateway"),
730
731
  },
731
732
  harness: {
732
- mode: answered?.harness?.mode ?? harnessMode,
733
+ mode: ready?.harness?.mode ?? harnessMode,
733
734
  state: harness ? [harness.state, harness.health].filter(Boolean).join(", ") : "absent",
734
735
  healthy: isUp(services, "harness"),
735
- ...(answered?.harness ? { connected: answered.harness.connected } : {}),
736
+ ...(ready?.harness ? { connected: ready.harness.connected } : {}),
736
737
  },
737
738
  services,
738
739
  };
@@ -746,9 +747,8 @@ function describeTenant(tenant) {
746
747
  const who = `${tenant.id ?? "(no id yet)"}${tenant.name ? ` (${tenant.name})` : ""}`;
747
748
  if (tenant.state === "open")
748
749
  return `${who} open`;
749
- return tenant.cause
750
- ? `${who} unavailable: ${tenant.cause.code}: ${tenant.cause.message} ${tenant.cause.repair}`
751
- : `${who} unavailable (opening)`;
750
+ const why = [tenant.cause, tenant.message].filter(Boolean).join(": ");
751
+ return `${who} unavailable${why ? `: ${why}` : " (opening)"}`;
752
752
  }
753
753
  async function status(deps, args) {
754
754
  const usage = "nylorun status [--tenant <name>] [--json]";
@@ -769,11 +769,9 @@ async function status(deps, args) {
769
769
  if (result.tenant)
770
770
  out(`Tenant id ${describeTenant(result.tenant)}`);
771
771
  const runtimeDetail = result.runtime.healthy
772
- ? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}`
772
+ ? `healthy, ${result.runtime.ready ? "ready" : "not ready"}, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}`
773
773
  : "not answering";
774
774
  out(`Runtime ${result.runtime.url ?? "?"} ${runtimeDetail}`);
775
- if (result.runtime.adminUrl)
776
- out(`Admin API ${result.runtime.adminUrl} (operators only, never proxied)`);
777
775
  out(`Studio ${result.studio.url ?? "?"} ${result.studio.state} (log in with "nylorun studio")`);
778
776
  if (result.studio.embedOrigins?.length)
779
777
  out(`Embeds ${result.studio.embedOrigins.join(" ")} (may show Studio in a frame)`);
@@ -884,7 +882,6 @@ async function ls(deps, args) {
884
882
  ...(persisted.studioPort ? { studioUrl: studioOrigin(persisted.studioPort) } : {}),
885
883
  ports: {
886
884
  ...(persisted.runtimePort ? { runtime: persisted.runtimePort } : {}),
887
- ...(persisted.adminPort ? { admin: persisted.adminPort } : {}),
888
885
  ...(persisted.studioPort ? { studio: persisted.studioPort } : {}),
889
886
  ...(persisted.restatePort ? { restate: persisted.restatePort } : {}),
890
887
  },
@@ -964,9 +961,6 @@ async function runningStack(ctx, options) {
964
961
  name: ctx.name,
965
962
  home: ctx.paths.root,
966
963
  runtimeUrl,
967
- adminUrl: persisted.adminPort
968
- ? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
969
- : runtimeUrl,
970
964
  hostId: host.hostId,
971
965
  adminKey,
972
966
  studioPort: persisted.studioPort,
@@ -986,7 +980,6 @@ async function ensureSelected(ctx, options) {
986
980
  name: ctx.name,
987
981
  home: ctx.paths.root,
988
982
  runtimeUrl: started.runtimeUrl,
989
- adminUrl: started.adminUrl,
990
983
  hostId: started.hostId,
991
984
  adminKey: started.adminKey,
992
985
  studioPort: started.studioPort,
@@ -1002,47 +995,53 @@ async function runningSelected(deps, options) {
1002
995
  const running = await runningStack(ctx, { studio: false });
1003
996
  if (!running)
1004
997
  throw new CliError(`Tenant ${ctx.name} is not running. Run "nylorun start" first.`, 3);
1005
- const tenant = await fetchTenant(deps, running.adminUrl, running.adminKey);
1006
- if (!tenant?.id || tenant.state !== "open")
998
+ if ((await fetchReady(deps, running.runtimeUrl))?.tenant !== true)
1007
999
  throw new CliError(`Tenant ${ctx.name} is not open. See "nylorun status".`, 7);
1008
1000
  return {
1009
1001
  ctx,
1010
- admin: {
1002
+ running: {
1011
1003
  name: ctx.name,
1012
1004
  runtimeUrl: running.runtimeUrl,
1013
1005
  paths: ctx.paths,
1014
- fetch: deps.fetch,
1015
- adminUrl: running.adminUrl,
1016
- adminKey: running.adminKey,
1006
+ ...operateEndpoint(ctx),
1017
1007
  },
1018
1008
  };
1019
1009
  }
1020
1010
  /**
1021
- * The selected Tenant's Admin API while it runs; exit 3 when it is not running, 7 when it is
1022
- * not open.
1011
+ * The selected Tenant's `nylorun-operate` while it runs; exit 3 when it is not running, 7 when
1012
+ * it is not open.
1023
1013
  */
1024
- export async function runningAdmin(deps, options = {}) {
1025
- return (await runningSelected(deps, options)).admin;
1014
+ export async function runningOperate(deps, options = {}) {
1015
+ return (await runningSelected(deps, options)).running;
1026
1016
  }
1027
1017
  /**
1028
1018
  * The selected Tenant's API while it runs: commands that read or change the Tenant's data never
1029
1019
  * start it. Exit 3 when it is not running, 7 when it is not open.
1030
1020
  */
1031
1021
  export async function runningTenantApi(deps, options = {}) {
1032
- const { ctx, admin } = await runningSelected(deps, options);
1022
+ const { ctx, running } = await runningSelected(deps, options);
1023
+ const api = (applicationKey, managementKey) => ({
1024
+ name: ctx.name,
1025
+ runtimeUrl: running.runtimeUrl,
1026
+ applicationKey,
1027
+ managementKey,
1028
+ });
1029
+ const reaches = (key) => keyAuthenticates(deps.fetch, running.runtimeUrl, key);
1033
1030
  if (ctx.projectDir && ctx.link?.format === 3 && ctx.link.tenant === ctx.name) {
1034
1031
  const project = await readCredentialsFile(credentialsPath(ctx.projectDir));
1035
- if (project && (await keyAuthenticates(deps.fetch, admin.runtimeUrl, project.applicationKey)))
1036
- return { name: ctx.name, runtimeUrl: admin.runtimeUrl, applicationKey: project.applicationKey };
1032
+ if (project?.management &&
1033
+ (await reaches(project.applicationKey)) &&
1034
+ (await reaches(project.management.key)))
1035
+ return api(project.applicationKey, project.management.key);
1037
1036
  }
1038
- const key = await hostKey({
1039
- admin,
1040
- runtimeUrl: admin.runtimeUrl,
1037
+ const keys = await hostKey({
1038
+ endpoint: running,
1039
+ runtimeUrl: running.runtimeUrl,
1041
1040
  id: CLI_KEY_ID,
1042
1041
  file: ctx.paths.cliCredentials,
1043
1042
  lock: ctx.paths.keysLock,
1044
1043
  });
1045
- return { name: ctx.name, runtimeUrl: admin.runtimeUrl, applicationKey: key.applicationKey };
1044
+ return api(keys.applicationKey, keys.management.key);
1046
1045
  }
1047
1046
  /**
1048
1047
  * Start the selected Tenant unless it is already running (`nylorun studio`): the
@@ -1120,7 +1119,7 @@ async function studio(deps, args, options = {}) {
1120
1119
  // Studio serves the one Tenant; the link's Tenant id stands in while it is opening.
1121
1120
  let next = options.next;
1122
1121
  if (next === undefined) {
1123
- const tenant = await fetchTenant(deps, running.adminUrl, running.adminKey);
1122
+ const tenant = await operateStatus(ctx);
1124
1123
  const linked = ctx.link?.tenant === ctx.name ? ctx.link.tenantId : undefined;
1125
1124
  const tenantId = tenant?.id ?? linked;
1126
1125
  if (tenantId)
@@ -22,17 +22,17 @@
22
22
  * The combined packing (blueprint D12): the `runtime` container runs the core
23
23
  * and loop services, and the `gateway` container runs gates: the Model Gate,
24
24
  * which alone reads model credentials, and the Tool Gate (F4.1), which alone
25
- * holds remote MCP connections and their credentials and POSTs every Action
26
- * delivery. Every model call, remote MCP call and delivery of the loop crosses it
25
+ * holds remote MCP connections and their credentials and makes every HTTP tool
26
+ * request. Every model call, remote MCP call and HTTP tool call of the loop crosses it
27
27
  * (NYLORUN_GATES_URL, with NYLORUN_GATES_TOKEN from `.env`, or a run's token from the
28
- * harness); MCP stdio servers run in the harness container. The gateway also runs keys (F4.2): the only
28
+ * harness). The gateway also runs keys (F4.2): the only
29
29
  * holder of the vault key, it runs every vault write that touches a secret and signs
30
30
  * every token (NYLORUN_KEYS_URL). The gateway mounts only the Host's Tenant directory
31
31
  * (`tenant/`) and its keys directory (`keys/`, the vault key), read-only: never
32
32
  * host-credentials.json. The runtime mounts the Host root with `keys/` and `docker/`
33
33
  * covered by empty read-only mounts, so it can read neither the vault key nor
34
34
  * Restate's private key and `.env`. The runtime does not wait for the gateway: while
35
- * it is down, model and MCP calls fail, deliveries are retried, vault writes and
35
+ * it is down, model, MCP and HTTP tool calls fail, vault writes and
36
36
  * token minting answer 503, and the session takes the next message.
37
37
  *
38
38
  * The Object store (blueprint D35) is RustFS, single node and single drive, pinned by digest,
@@ -53,14 +53,12 @@
53
53
  * CONNECT with the egress token, to the hosts the sandbox spec allows). The two HTTP listeners
54
54
  * accept that address as a Host.
55
55
  *
56
- * The harness (F6.2, `harness: "remote"`, the default) runs every agent turn, MCP stdio server
57
- * and workspace in its own container: the runtime image with `--service harness`, connected to
58
- * core's Harness API (`ws://runtime:4200/nylorun/harness/v1`) with NYLORUN_HARNESS_TOKEN, its
59
- * only credential, and calling models and remote MCP servers through the gateway with each
60
- * run's token. It mounts only the Tenant's `sandboxes/`, `plugin-data/`, `home/` and `tmp/`
61
- * under `/harness`, and the Host root's `plugins/` read-only at its own path (plugin roots name
62
- * paths on this machine; the runtime mounts it there too). NYLORUN_HARNESS=in-process in `.env`
63
- * rolls back to turns in the runtime container, without the harness service.
56
+ * The harness (F6.2, `harness: "remote"`, the default) runs every agent turn and workspace in
57
+ * its own container: the runtime image with `--service harness`, connected to core's Harness
58
+ * API (`ws://runtime:4200/nylorun/harness/v1`) with NYLORUN_HARNESS_TOKEN, its only
59
+ * credential, and calling models and remote MCP servers through the gateway with each run's
60
+ * token. It mounts only the Tenant's `sandboxes/` under `/harness`. NYLORUN_HARNESS=in-process
61
+ * in `.env` rolls back to turns in the runtime container, without the harness service.
64
62
  *
65
63
  * Three networks: `store` (internal: no egress) joins Postgres, s2-lite, Restate and RustFS to
66
64
  * the runtime and the gateway; `harness` joins the harness to the runtime and the gateway only;