nylorun 0.8.0-beta → 0.9.0-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,14 +1,10 @@
1
- import { DERIVED_PRINCIPAL_ID_PATTERN } from "@nylorun/core/compatibility";
2
1
  import { parseFrameAncestors } from "@nylorun/core/contracts";
3
2
  import { CliError } from "../errors.js";
4
3
  /**
5
- * Origins that may embed Studio by default: Babai Desktop's `nylorun` scheme on
6
- * macOS and Linux, and its WebView2 origin on Windows (Studio §8.9).
4
+ * Origins that may embed Studio by default: none. Embedding is opt-in: the operator lists
5
+ * the exact origins of the app that frames Studio (`--studio-embed-origin`, Studio §8.9).
7
6
  */
8
- export const DEFAULT_STUDIO_FRAME_ANCESTORS = [
9
- "nylorun://localhost",
10
- "http://nylorun.localhost",
11
- ];
7
+ export const DEFAULT_STUDIO_FRAME_ANCESTORS = [];
12
8
  const SANDBOX_KEYS = {
13
9
  token: "NYLORUN_SANDBOXES_TOKEN",
14
10
  image: "NYLORUN_SANDBOXES_IMAGE",
@@ -38,7 +34,6 @@ const KEYS = {
38
34
  studioFrameAncestors: "NYLORUN_STUDIO_FRAME_ANCESTORS",
39
35
  studioAnalyticsId: "NYLORUN_STUDIO_ANALYTICS_ID",
40
36
  tenantName: "NYLORUN_TENANT_NAME",
41
- derivedPrincipals: "NYLORUN_DERIVED_PRINCIPALS",
42
37
  };
43
38
  /** Compose .env values: single quotes keep a value literal (no interpolation). */
44
39
  function quote(key, value) {
@@ -104,8 +99,8 @@ export function renderEnvFile(env) {
104
99
  line("runtimeImage"),
105
100
  line("studioImage"),
106
101
  "",
107
- "# Exact origins that may show Studio in a frame (Babai Desktop). Kept across",
108
- "# starts; change with nylorun start --studio-embed-origin <origin>.",
102
+ "# Exact origins that may show Studio in a frame; none by default. Kept across",
103
+ "# starts; add with nylorun start --studio-embed-origin <origin>.",
109
104
  line("studioFrameAncestors"),
110
105
  "",
111
106
  "# Studio's anonymous usage analytics; empty when off. Set on every start: turn",
@@ -113,10 +108,8 @@ export function renderEnvFile(env) {
113
108
  line("studioAnalyticsId"),
114
109
  "",
115
110
  "# The Tenant's name, which the Runtime gives the Tenant it creates on the first",
116
- "# start, and the derived principals it registers on that Tenant (keys derived",
117
- "# from the admin key; `project` is the Project link's).",
111
+ "# start.",
118
112
  line("tenantName"),
119
- line("derivedPrincipals"),
120
113
  "",
121
114
  ...(sandboxes
122
115
  ? [
@@ -134,20 +127,6 @@ export function renderEnvFile(env) {
134
127
  : []),
135
128
  ].join("\n");
136
129
  }
137
- /**
138
- * `NYLORUN_DERIVED_PRINCIPALS`: comma-separated principal ids, as the Runtime accepts them
139
- * (not `studio`), with `project` first so the Project link's key always works.
140
- */
141
- export function parseDerivedPrincipals(raw, source) {
142
- const ids = raw
143
- .split(",")
144
- .map((entry) => entry.trim())
145
- .filter((entry) => entry !== "");
146
- for (const id of ids)
147
- if (!DERIVED_PRINCIPAL_ID_PATTERN.test(id) || id === "studio")
148
- throw new CliError(`${source} has '${id}': each entry must match ${DERIVED_PRINCIPAL_ID_PATTERN} and not be studio.`, 2);
149
- return [...new Set(["project", ...ids])];
150
- }
151
130
  /** Parse KEY=value lines; comments and blank lines are skipped. */
152
131
  export function parseEnvLines(text) {
153
132
  const values = new Map();
@@ -218,15 +197,6 @@ export function parsePersisted(text) {
218
197
  const objectStoreSecretKey = values.get(KEYS.objectStoreSecretKey);
219
198
  if (objectStoreSecretKey && /^[0-9a-f]{64}$/i.test(objectStoreSecretKey))
220
199
  out.objectStoreSecretKey = objectStoreSecretKey;
221
- const principals = values.get(KEYS.derivedPrincipals);
222
- if (principals !== undefined) {
223
- try {
224
- out.derivedPrincipals = parseDerivedPrincipals(principals, `${KEYS.derivedPrincipals} in docker/.env`);
225
- }
226
- catch {
227
- /* rewritten from the default on the next start */
228
- }
229
- }
230
200
  const sandboxes = parseSandboxes(values);
231
201
  if (sandboxes)
232
202
  out.sandboxes = sandboxes;
@@ -10,6 +10,12 @@ export declare function stackCommand(name: string, args: readonly string[], env:
10
10
  /** Entry for `nylorun sandbox ls|rm`. */
11
11
  export declare function sandboxCommand(args: readonly string[], env: Readonly<Record<string, string | undefined>>): Promise<number>;
12
12
  export { sandboxUsage } from "./sandbox.js";
13
+ /** Entry for `nylorun mcp connect`. */
14
+ export declare function mcpCommand(args: readonly string[], env: Readonly<Record<string, string | undefined>>): Promise<number>;
15
+ export { mcpUsage } from "./mcp.js";
16
+ /** Entry for `nylorun key put|list|rm`. */
17
+ export declare function keyCommand(args: readonly string[], env: Readonly<Record<string, string | undefined>>): Promise<number>;
18
+ export { keyUsage } from "./keys.js";
13
19
  /** `nylorun studio`, landing on `next` when given, else on the Tenant's page. */
14
20
  export declare function studioCommand(args: readonly string[], env: Readonly<Record<string, string | undefined>>, options?: {
15
21
  next?: string;
@@ -2,7 +2,9 @@ import { spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { runStackCommand, runStudioCommand } from "./commands.js";
4
4
  import { spawnDocker } from "./docker.js";
5
+ import { keyCommand as runKeyCommand } from "./keys.js";
5
6
  import { sandboxCommand as runSandboxCommand } from "./sandbox.js";
7
+ import { mcpCommand as runMcpCommand } from "./mcp.js";
6
8
  import { pinnedVersion } from "./versions.js";
7
9
  import { loopbackPorts } from "./ports.js";
8
10
  export { ensureStack, isStackCommand, readStackStatus, stackUsage, studioLoginUrl, tenantStudioPath, STACK_SERVICES, } from "./commands.js";
@@ -20,7 +22,6 @@ function browserCommand(env) {
20
22
  */
21
23
  const STACK_ENV_OWNED = [
22
24
  "NYLORUN_TENANT_NAME",
23
- "NYLORUN_DERIVED_PRINCIPALS",
24
25
  "NYLORUN_STUDIO_ANALYTICS_ID",
25
26
  ];
26
27
  /** Real dependencies: docker on PATH, global fetch, the terminal. */
@@ -85,6 +86,16 @@ export async function sandboxCommand(args, env) {
85
86
  return await runSandboxCommand(defaultStackDeps(env), args);
86
87
  }
87
88
  export { sandboxUsage } from "./sandbox.js";
89
+ /** Entry for `nylorun mcp connect`. */
90
+ export async function mcpCommand(args, env) {
91
+ return await runMcpCommand(defaultStackDeps(env), args);
92
+ }
93
+ export { mcpUsage } from "./mcp.js";
94
+ /** Entry for `nylorun key put|list|rm`. */
95
+ export async function keyCommand(args, env) {
96
+ return await runKeyCommand(defaultStackDeps(env), args);
97
+ }
98
+ export { keyUsage } from "./keys.js";
88
99
  /** `nylorun studio`, landing on `next` when given, else on the Tenant's page. */
89
100
  export async function studioCommand(args, env, options = {}) {
90
101
  return await runStudioCommand(args, defaultStackDeps(env), options);
@@ -0,0 +1,4 @@
1
+ import { type StackDeps } from "./commands.js";
2
+ export declare const keyUsage = " key put <id> [--tenant <name>] create the Tenant's key <id>, or rotate it (the old key stops working); prints the key once\n key list [--tenant <name>] [--json] the Tenant's keys: id, role, when issued (never the keys)\n key rm <id> [--tenant <name>] delete key <id>: it stops working at once";
3
+ /** `nylorun key <put|list|rm> ...` */
4
+ export declare function keyCommand(deps: StackDeps, args: readonly string[]): Promise<number>;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `nylorun key put | list | rm`: the running local Tenant's operator keys (F9 I1, Host feature
3
+ * `operator-keys`), through its Admin API with the admin key. Revocable application keys by
4
+ * name, for app servers and other clients; none of these starts the Tenant.
5
+ */
6
+ import { CliError } from "../errors.js";
7
+ import { parseStackFlags, runningAdmin } from "./commands.js";
8
+ import { deleteOperatorKey, listOperatorKeys, putOperatorKey } from "./operator-keys.js";
9
+ export const keyUsage = ` key put <id> [--tenant <name>] create the Tenant's key <id>, or rotate it (the old key stops working); prints the key once
10
+ key list [--tenant <name>] [--json] the Tenant's keys: id, role, when issued (never the keys)
11
+ key rm <id> [--tenant <name>] delete key <id>: it stops working at once`;
12
+ const usageError = (message) => new CliError(message, 2);
13
+ async function put(deps, args) {
14
+ const usage = "nylorun key put <id> [--tenant <name>]";
15
+ const flags = parseStackFlags(args, { values: ["--tenant"] }, usage);
16
+ if (flags.rest.length !== 1)
17
+ throw usageError(`Usage: ${usage}`);
18
+ const id = flags.rest[0];
19
+ const name = flags.values.get("--tenant");
20
+ const admin = await runningAdmin(deps, name === undefined ? {} : { name });
21
+ const key = await putOperatorKey(admin, id);
22
+ // The key alone on stdout, for scripts; it is not shown again.
23
+ deps.out(key.key);
24
+ deps.err(`${key.rotated ? "Rotated" : "Created"} key ${key.id} on Tenant ${admin.name}${key.rotated ? " (the previous key no longer works)" : ""}. Store it now: it is not shown again.`);
25
+ return 0;
26
+ }
27
+ async function list(deps, args) {
28
+ const usage = "nylorun key list [--tenant <name>] [--json]";
29
+ const flags = parseStackFlags(args, { booleans: ["--json"], values: ["--tenant"] }, usage);
30
+ if (flags.rest.length)
31
+ throw usageError(`Usage: ${usage}`);
32
+ const name = flags.values.get("--tenant");
33
+ const admin = await runningAdmin(deps, name === undefined ? {} : { name });
34
+ const keys = await listOperatorKeys(admin);
35
+ if (flags.booleans.has("--json")) {
36
+ deps.out(JSON.stringify(keys, null, 2));
37
+ return 0;
38
+ }
39
+ const rows = [["ID", "ROLE", "CREATED"], ...keys.map((key) => [key.id, key.role, key.createdAt])];
40
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
41
+ for (const row of rows)
42
+ deps.out(row
43
+ .map((cell, column) => (column === row.length - 1 ? cell : cell.padEnd(widths[column])))
44
+ .join(" "));
45
+ return 0;
46
+ }
47
+ async function rm(deps, args) {
48
+ const usage = "nylorun key rm <id> [--tenant <name>]";
49
+ const flags = parseStackFlags(args, { values: ["--tenant"] }, usage);
50
+ if (flags.rest.length !== 1)
51
+ throw usageError(`Usage: ${usage}`);
52
+ const id = flags.rest[0];
53
+ const name = flags.values.get("--tenant");
54
+ const admin = await runningAdmin(deps, name === undefined ? {} : { name });
55
+ if (!(await deleteOperatorKey(admin, id))) {
56
+ deps.err(`No key ${id} on Tenant ${admin.name}.`);
57
+ return 1;
58
+ }
59
+ deps.out(`Deleted key ${id}: it no longer works.`);
60
+ return 0;
61
+ }
62
+ /** `nylorun key <put|list|rm> ...` */
63
+ export async function keyCommand(deps, args) {
64
+ const [sub, ...rest] = args;
65
+ if (sub === "put")
66
+ return put(deps, rest);
67
+ if (sub === "list" || sub === "ls")
68
+ return list(deps, rest);
69
+ if (sub === "rm" || sub === "delete")
70
+ return rm(deps, rest);
71
+ throw usageError(`Usage:\n${keyUsage}`);
72
+ }
@@ -0,0 +1,8 @@
1
+ import { type StackDeps } from "./commands.js";
2
+ export declare const mcpUsage = " mcp connect <url> --server <name> [--vault <id>] [--client-id <id>] [--tenant <name>] [--no-open]\n sign the Tenant in to a remote MCP server with OAuth; the credential goes to the\n installation vault mcp (or --vault), which sessions attach";
3
+ /** The installation vault `connect` uses unless `--vault` names one. */
4
+ export declare const MCP_VAULT_NAME = "mcp";
5
+ /** How long `connect` waits for the browser sign-in: the Runtime keeps it for ten minutes. */
6
+ export declare const CONNECT_TIMEOUT_MS: number;
7
+ /** `nylorun mcp <connect> ...` */
8
+ export declare function mcpCommand(deps: StackDeps, args: readonly string[]): Promise<number>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `nylorun mcp connect <url> --server <name>`: signs the running local Tenant in to a remote MCP
3
+ * server with OAuth (F9 C2), through its Tenant API with an operator key (`runningTenantApi`).
4
+ * The Runtime's keys module runs discovery, registration and the code exchange and seals the
5
+ * credential in an installation vault (`mcp` unless `--vault` names one); this command only
6
+ * opens the browser and waits for the credential to appear. It never starts the Tenant.
7
+ */
8
+ import { randomUUID } from "node:crypto";
9
+ import { PROTOCOL_HEADER, PROTOCOL_VERSION } from "@nylorun/core/compatibility";
10
+ import { CliError } from "../errors.js";
11
+ import { parseStackFlags, runningTenantApi } from "./commands.js";
12
+ export const mcpUsage = ` mcp connect <url> --server <name> [--vault <id>] [--client-id <id>] [--tenant <name>] [--no-open]
13
+ sign the Tenant in to a remote MCP server with OAuth; the credential goes to the
14
+ installation vault mcp (or --vault), which sessions attach`;
15
+ /** The installation vault `connect` uses unless `--vault` names one. */
16
+ export const MCP_VAULT_NAME = "mcp";
17
+ /** How long `connect` waits for the browser sign-in: the Runtime keeps it for ten minutes. */
18
+ export const CONNECT_TIMEOUT_MS = 10 * 60_000;
19
+ const usageError = (message) => new CliError(message, 2);
20
+ async function call(deps, api, method, path, body) {
21
+ const response = await deps.fetch(`${api.runtimeUrl}${path}`, {
22
+ method,
23
+ headers: {
24
+ authorization: `Bearer ${api.applicationKey}`,
25
+ [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
26
+ accept: "application/json",
27
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
28
+ },
29
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
30
+ redirect: "error",
31
+ signal: AbortSignal.timeout(150_000),
32
+ });
33
+ const answer = (await response.json().catch(() => ({})));
34
+ if (!response.ok)
35
+ throw new CliError(`${method} ${path} returned ${response.status}${typeof answer.message === "string" ? `: ${answer.message}` : ""}`, 1);
36
+ return answer;
37
+ }
38
+ /** The URL as the vault binds it: origin, path and query, no default port, no fragment. */
39
+ function bindingUrl(input) {
40
+ let url;
41
+ try {
42
+ url = new URL(input);
43
+ }
44
+ catch {
45
+ throw usageError(`${input} is not a URL.`);
46
+ }
47
+ if (url.protocol !== "https:" && url.protocol !== "http:")
48
+ throw usageError(`${input} is not an http(s) URL.`);
49
+ if (url.username || url.password || url.hash)
50
+ throw usageError(`${input} must not carry userinfo or a fragment.`);
51
+ return `${url.origin}${url.pathname}${url.search}`;
52
+ }
53
+ /** The installation vault named `mcp`, created when there is none. */
54
+ async function mcpVault(deps, api) {
55
+ const { vaults } = await call(deps, api, "GET", "/v1/vaults");
56
+ const found = vaults.find((vault) => vault.ownerUserId === "installation" && vault.name === MCP_VAULT_NAME);
57
+ if (found)
58
+ return found;
59
+ const key = `nylorun-mcp-${randomUUID()}`;
60
+ return await call(deps, api, "POST", "/v1/vaults", {
61
+ requestId: key,
62
+ idempotencyKey: key,
63
+ name: MCP_VAULT_NAME,
64
+ scope: "installation",
65
+ });
66
+ }
67
+ /** Each credential of the vault bound to `url`, by id, with when it last changed. */
68
+ async function credentialsFor(deps, api, vaultId, url) {
69
+ const { credentials } = await call(deps, api, "GET", `/v1/vaults/${encodeURIComponent(vaultId)}/credentials`);
70
+ return new Map(credentials
71
+ .filter((item) => item.type === "oauth" && item.binding.url === url)
72
+ .map((item) => [item.id, item.rotatedAt ?? item.createdAt]));
73
+ }
74
+ async function connect(deps, args) {
75
+ const usage = "nylorun mcp connect <url> --server <name> [--vault <id>] [--client-id <id>] [--tenant <name>] [--no-open]";
76
+ const flags = parseStackFlags(args, { booleans: ["--no-open"], values: ["--server", "--vault", "--client-id", "--tenant"] }, usage);
77
+ if (flags.rest.length !== 1)
78
+ throw usageError(`Usage: ${usage}`);
79
+ const server = flags.values.get("--server");
80
+ if (!server)
81
+ throw usageError(`--server names the MCP server, as the agent declares it. Usage: ${usage}`);
82
+ const url = bindingUrl(flags.rest[0]);
83
+ const clientId = flags.values.get("--client-id");
84
+ const name = flags.values.get("--tenant");
85
+ const api = await runningTenantApi(deps, name === undefined ? {} : { name });
86
+ const vaultId = flags.values.get("--vault") ?? (await mcpVault(deps, api)).id;
87
+ const before = await credentialsFor(deps, api, vaultId, url);
88
+ const started = await call(deps, api, "POST", `/v1/vaults/${encodeURIComponent(vaultId)}/oauth/start`, { url, server, ...(clientId === undefined ? {} : { clientId }) });
89
+ deps.out(`Sign in to ${server} in your browser:`);
90
+ deps.out(` ${started.authorizeUrl}`);
91
+ if (!flags.booleans.has("--no-open"))
92
+ await deps.openBrowser(started.authorizeUrl);
93
+ deps.out("Waiting for the sign-in to finish…");
94
+ const deadline = Math.min(Date.now() + CONNECT_TIMEOUT_MS, Date.parse(started.expiresAt) || Infinity);
95
+ const pollMs = deps.pollMs ?? 1000;
96
+ while (Date.now() < deadline) {
97
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
98
+ const now = await credentialsFor(deps, api, vaultId, url);
99
+ for (const [id, changed] of now)
100
+ if (before.get(id) !== changed) {
101
+ deps.out(`Connected ${server} (${url}): credential ${id} in installation vault ${vaultId}.`);
102
+ deps.out(`Sessions use it when they attach the vault: vaultIds: ["${vaultId}"].`);
103
+ return 0;
104
+ }
105
+ }
106
+ throw new CliError(`The sign-in to ${server} did not finish in time. Run "nylorun mcp connect" again; the browser page says why when the server refused it.`, 1);
107
+ }
108
+ /** `nylorun mcp <connect> ...` */
109
+ export async function mcpCommand(deps, args) {
110
+ const [sub, ...rest] = args;
111
+ if (sub === "connect")
112
+ return connect(deps, rest);
113
+ throw usageError(`Usage:\n${mcpUsage}`);
114
+ }
@@ -0,0 +1,39 @@
1
+ import type { OperatorKey, PutOperatorKeyResponse } from "@nylorun/core/contracts";
2
+ import { type KeyCredentials } from "../project/link.js";
3
+ import type { FetchLike } from "./studio-login.js";
4
+ /** Where a local Tenant's Admin API answers, and its admin key. */
5
+ export interface AdminEndpoint {
6
+ fetch: FetchLike;
7
+ adminUrl: string;
8
+ adminKey: string;
9
+ }
10
+ /** The key `nylorun start` gives the projects it links. */
11
+ export declare const PROJECT_KEY_ID = "project";
12
+ /** The key nylorun commands use outside a linked project. */
13
+ export declare const CLI_KEY_ID = "cli";
14
+ /** Creates key `id` or rotates it; the answer holds the key, this once. */
15
+ export declare function putOperatorKey(admin: AdminEndpoint, id: string): Promise<PutOperatorKeyResponse>;
16
+ export declare function listOperatorKeys(admin: AdminEndpoint): Promise<OperatorKey[]>;
17
+ /** Deletes key `id`: false when there is none. */
18
+ export declare function deleteOperatorKey(admin: AdminEndpoint, id: string): Promise<boolean>;
19
+ /**
20
+ * Whether `key` reaches the Tenant API at `runtimeUrl`: one authenticated read
21
+ * (`GET /v1/tenant`). An unknown key is the opaque 404; another answer is an error.
22
+ */
23
+ export declare function keyAuthenticates(fetch: FetchLike, runtimeUrl: string, key: string): Promise<boolean>;
24
+ /**
25
+ * The operator key `id` kept in the Host root file `file` (0600): the file's key while it
26
+ * still authenticates, else `adopt` when it does (a key a project already holds), else a new
27
+ * key put through the Admin API and written to the file. One process at a time (`lock`), so
28
+ * concurrent commands share one key instead of rotating each other's.
29
+ */
30
+ export declare function hostKey(options: {
31
+ admin: AdminEndpoint;
32
+ runtimeUrl: string;
33
+ id: string;
34
+ file: string;
35
+ lock: string;
36
+ adopt?: KeyCredentials;
37
+ }): Promise<KeyCredentials & {
38
+ put: boolean;
39
+ }>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Operator keys (F9 I1, Host feature `operator-keys`) as the nylorun commands use them: the
3
+ * Admin API's `/v1/admin/keys` on a local Tenant's operator listener, with its admin key, and
4
+ * the keys nylorun keeps in the Host root (`project`, `cli`). nylorun depends on
5
+ * `@nylorun/core` only, so these are plain requests (`admin.keys` in `@nylorun/admin` makes
6
+ * the same).
7
+ */
8
+ import { open, rm, stat } from "node:fs/promises";
9
+ import { PROTOCOL_HEADER, PROTOCOL_VERSION } from "@nylorun/core/compatibility";
10
+ import { CliError } from "../errors.js";
11
+ import { readCredentialsFile, writeCredentialsFile, } from "../project/link.js";
12
+ /** The key `nylorun start` gives the projects it links. */
13
+ export const PROJECT_KEY_ID = "project";
14
+ /** The key nylorun commands use outside a linked project. */
15
+ export const CLI_KEY_ID = "cli";
16
+ async function adminRequest(admin, method, path) {
17
+ let response;
18
+ try {
19
+ response = await admin.fetch(`${admin.adminUrl}${path}`, {
20
+ method,
21
+ headers: {
22
+ authorization: `Bearer ${admin.adminKey}`,
23
+ [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
24
+ accept: "application/json",
25
+ },
26
+ redirect: "error",
27
+ signal: AbortSignal.timeout(15_000),
28
+ });
29
+ }
30
+ catch (error) {
31
+ throw new CliError(`The Admin API at ${admin.adminUrl} did not answer ${method} ${path} (${error instanceof Error ? error.message : String(error)}).`, 7);
32
+ }
33
+ const body = (await response.json().catch(() => ({})));
34
+ return { status: response.status, body };
35
+ }
36
+ function refused(method, path, answer) {
37
+ const message = typeof answer.body.message === "string" ? `: ${answer.body.message}` : "";
38
+ // An older Runtime has no key routes: the Admin API's "Route not found".
39
+ if (answer.status === 404 && answer.body.message === "Route not found")
40
+ return new CliError(`The Runtime does not serve operator keys (Host feature operator-keys). Update it: npx nylorun@latest start.`, 1);
41
+ return new CliError(`${method} ${path} returned ${answer.status}${message}`, 1);
42
+ }
43
+ const keyPath = (id) => `/v1/admin/keys/${encodeURIComponent(id)}`;
44
+ /** Creates key `id` or rotates it; the answer holds the key, this once. */
45
+ export async function putOperatorKey(admin, id) {
46
+ const answer = await adminRequest(admin, "PUT", keyPath(id));
47
+ if (answer.status !== 200 || typeof answer.body.key !== "string")
48
+ throw refused("PUT", keyPath(id), answer);
49
+ return answer.body;
50
+ }
51
+ export async function listOperatorKeys(admin) {
52
+ const answer = await adminRequest(admin, "GET", "/v1/admin/keys");
53
+ if (answer.status !== 200 || !Array.isArray(answer.body.keys))
54
+ throw refused("GET", "/v1/admin/keys", answer);
55
+ return answer.body.keys;
56
+ }
57
+ /** Deletes key `id`: false when there is none. */
58
+ export async function deleteOperatorKey(admin, id) {
59
+ const answer = await adminRequest(admin, "DELETE", keyPath(id));
60
+ if (answer.status === 200)
61
+ return true;
62
+ if (answer.status === 404 && answer.body.message === `No key ${id}`)
63
+ return false;
64
+ throw refused("DELETE", keyPath(id), answer);
65
+ }
66
+ /**
67
+ * Whether `key` reaches the Tenant API at `runtimeUrl`: one authenticated read
68
+ * (`GET /v1/tenant`). An unknown key is the opaque 404; another answer is an error.
69
+ */
70
+ export async function keyAuthenticates(fetch, runtimeUrl, key) {
71
+ let response;
72
+ try {
73
+ response = await fetch(`${runtimeUrl}/v1/tenant`, {
74
+ headers: {
75
+ authorization: `Bearer ${key}`,
76
+ [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
77
+ accept: "application/json",
78
+ },
79
+ redirect: "error",
80
+ signal: AbortSignal.timeout(10_000),
81
+ });
82
+ }
83
+ catch (error) {
84
+ throw new CliError(`The Runtime at ${runtimeUrl} did not answer (${error instanceof Error ? error.message : String(error)}).`, 7);
85
+ }
86
+ await response.arrayBuffer().catch(() => undefined);
87
+ if (response.ok)
88
+ return true;
89
+ if (response.status === 401 || response.status === 403 || response.status === 404)
90
+ return false;
91
+ throw new CliError(`GET ${runtimeUrl}/v1/tenant returned ${response.status}.`, 7);
92
+ }
93
+ const LOCK_TIMEOUT_MS = 30_000;
94
+ /** A lock older than this was left by a process that died. */
95
+ const LOCK_STALE_MS = 60_000;
96
+ /** Runs `fn` holding the lock file `path` (created exclusively), so one process puts a key. */
97
+ async function withLock(path, fn) {
98
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
99
+ for (;;) {
100
+ try {
101
+ const handle = await open(path, "wx", 0o600);
102
+ await handle.close();
103
+ break;
104
+ }
105
+ catch (error) {
106
+ if (error.code !== "EEXIST")
107
+ throw error;
108
+ const held = await stat(path).catch(() => undefined);
109
+ if (held && Date.now() - held.mtimeMs > LOCK_STALE_MS) {
110
+ await rm(path, { force: true });
111
+ continue;
112
+ }
113
+ if (Date.now() > deadline)
114
+ throw new CliError(`Another nylorun command holds ${path}. Remove it if none runs.`, 1);
115
+ await new Promise((resolve) => setTimeout(resolve, 100));
116
+ }
117
+ }
118
+ try {
119
+ return await fn();
120
+ }
121
+ finally {
122
+ await rm(path, { force: true });
123
+ }
124
+ }
125
+ /**
126
+ * The operator key `id` kept in the Host root file `file` (0600): the file's key while it
127
+ * still authenticates, else `adopt` when it does (a key a project already holds), else a new
128
+ * key put through the Admin API and written to the file. One process at a time (`lock`), so
129
+ * concurrent commands share one key instead of rotating each other's.
130
+ */
131
+ export async function hostKey(options) {
132
+ const { admin, runtimeUrl, id, file } = options;
133
+ return await withLock(options.lock, async () => {
134
+ const kept = await readCredentialsFile(file);
135
+ if (kept && (await keyAuthenticates(admin.fetch, runtimeUrl, kept.applicationKey)))
136
+ return { ...kept, put: false };
137
+ if (options.adopt && (await keyAuthenticates(admin.fetch, runtimeUrl, options.adopt.applicationKey))) {
138
+ await writeCredentialsFile(file, options.adopt);
139
+ return { ...options.adopt, put: false };
140
+ }
141
+ const put = await putOperatorKey(admin, id);
142
+ const credentials = { applicationKey: put.key, principalId: put.id };
143
+ await writeCredentialsFile(file, credentials);
144
+ return { ...credentials, put: true };
145
+ });
146
+ }
@@ -8,8 +8,25 @@ export interface StackPaths {
8
8
  credentials: string;
9
9
  /** host-state.json: written only by a launcher-managed Runtime. */
10
10
  state: string;
11
+ /**
12
+ * cli-credentials.json: the operator key `cli` that `nylorun` commands use outside a linked
13
+ * project (`nylorun sandbox`), mode 0600.
14
+ */
15
+ cliCredentials: string;
16
+ /**
17
+ * project-credentials.json: the operator key `project` that `nylorun start` gives every
18
+ * project it links to this Tenant (a copy of their `.nylorun/credentials.json`), mode 0600.
19
+ */
20
+ projectCredentials: string;
21
+ /** operator-keys.lock: held while a command puts one of the keys above. */
22
+ keysLock: string;
11
23
  /** tenant.json: the Tenant's name and the project it was created for. */
12
24
  record: string;
25
+ /**
26
+ * identity.yaml: the trusted issuers (F9 I2), written by the operator. When it exists the
27
+ * runtime reads it at boot (`NYLORUN_IDENTITY_FILE`).
28
+ */
29
+ identity: string;
13
30
  /** The Tenant directory: homes, sandboxes, plugin data, logs. */
14
31
  tenant: string;
15
32
  /**
@@ -7,7 +7,11 @@ export function stackPaths(hostRoot) {
7
7
  config: join(root, "host.json"),
8
8
  credentials: join(root, "host-credentials.json"),
9
9
  state: join(root, "host-state.json"),
10
+ cliCredentials: join(root, "cli-credentials.json"),
11
+ projectCredentials: join(root, "project-credentials.json"),
12
+ keysLock: join(root, "operator-keys.lock"),
10
13
  record: join(root, "tenant.json"),
14
+ identity: join(root, "identity.yaml"),
11
15
  tenant: join(root, "tenant"),
12
16
  harness: {
13
17
  sandboxes: join(root, "tenant", "sandboxes"),
@@ -32,11 +32,9 @@ export declare function prepareStack(input: {
32
32
  ports: PortProbe;
33
33
  /** Ports other Tenants keep in their `.env`: never chosen for a port not chosen yet. */
34
34
  reserved?: ReadonlySet<number>;
35
- /** `NYLORUN_DERIVED_PRINCIPALS` from the environment of `nylorun start`; else kept. */
36
- derivedPrincipals?: string;
37
35
  /**
38
- * Changes to the origins that may embed Studio: `reset` goes back to the
39
- * defaults, `add` appends (for example a desktop app's dev server).
36
+ * Changes to the origins that may embed Studio: `reset` empties the list (the
37
+ * default), `add` appends (for example a desktop app's dev server).
40
38
  */
41
39
  studioEmbedOrigins?: {
42
40
  add?: readonly string[];
@@ -1,9 +1,10 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
2
3
  import { chmod, readFile, rename, writeFile } from "node:fs/promises";
3
4
  import { renderComposeFile } from "./compose-file.js";
4
5
  import { parseFrameAncestors } from "@nylorun/core/contracts";
5
6
  import { CliError } from "../errors.js";
6
- import { DEFAULT_STUDIO_FRAME_ANCESTORS, parseDerivedPrincipals, parsePersisted, renderEnvFile, } from "./env-file.js";
7
+ import { DEFAULT_STUDIO_FRAME_ANCESTORS, parsePersisted, renderEnvFile, } from "./env-file.js";
7
8
  import { sandboxesEnabled } from "../sandbox/cluster-file.js";
8
9
  import { ensureHostCredentials, ensureVaultKey, ensureHostLayout, writeStackHostConfig, } from "./host-files.js";
9
10
  import { choosePort, DEFAULT_PORTS } from "./ports.js";
@@ -86,9 +87,6 @@ export async function prepareStack(input) {
86
87
  studioFrameAncestors,
87
88
  studioAnalyticsId: input.studioAnalyticsId ?? "",
88
89
  tenantName: input.name,
89
- derivedPrincipals: (input.derivedPrincipals?.trim()
90
- ? parseDerivedPrincipals(input.derivedPrincipals, "NYLORUN_DERIVED_PRINCIPALS")
91
- : (persisted.derivedPrincipals ?? ["project"])).join(","),
92
90
  };
93
91
  const sandboxes = input.sandboxes === null
94
92
  ? undefined
@@ -108,6 +106,8 @@ export async function prepareStack(input) {
108
106
  harness: env.harness,
109
107
  ...(env.sandboxes ? { sandboxes: true } : {}),
110
108
  ...(env.restateUi ? { restateUi: true } : {}),
109
+ // The trusted issuers, when the operator wrote an identity file; a change takes a restart.
110
+ ...(existsSync(paths.identity) ? { identity: true } : {}),
111
111
  }), 0o644);
112
112
  return { env, host, adminKey, firstRun };
113
113
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `nylorun sandbox ls | rm`: the running local Tenant's sandbox resources (Host feature
3
- * `sandboxes`), through its Tenant API as the Project's derived principal. Neither starts the
3
+ * `sandboxes`), through its Tenant API with an operator key (`runningTenantApi`). Neither starts the
4
4
  * Tenant. `nylorun sandbox enable|disable|status` (sandbox pods on a cluster, F7.2) live in
5
5
  * `../sandbox/commands.ts`.
6
6
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nylorun",
3
- "version": "0.8.0-beta",
3
+ "version": "0.9.0-beta",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Set up and run local Nylorun Tenants (Runtime and Studio) with Docker Compose",
@@ -30,7 +30,7 @@
30
30
  "prepack": "npm run build"
31
31
  },
32
32
  "dependencies": {
33
- "@nylorun/core": "0.12.0-beta"
33
+ "@nylorun/core": "0.13.0-beta"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "npm:@typescript/typescript6@^6.0.2",
@@ -41,7 +41,7 @@
41
41
  "nylorun": "dist/cli.js"
42
42
  },
43
43
  "nylorun": {
44
- "runtime": "0.16.0-beta",
45
- "studio": "0.19.0-beta"
44
+ "runtime": "0.17.0-beta",
45
+ "studio": "0.20.0-beta"
46
46
  }
47
47
  }
@@ -1,8 +0,0 @@
1
- /** The derived principal a Project uses; the local Runtime registers it on its Tenant. */
2
- export declare const PROJECT_PRINCIPAL_ID = "project";
3
- /**
4
- * Key of a derived principal on the local Tenant, from the admin key. A copy of
5
- * `deriveTenantKey` in `@nylorun/admin` (nylorun depends on `@nylorun/core` only); both are
6
- * tested against the same fixed vector.
7
- */
8
- export declare function deriveTenantKey(adminKey: string, tenantId: string, principalId: string): string;