@nylorun/runtime 0.9.0-beta → 0.11.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.
- package/CHANGELOG.md +81 -0
- package/README.md +110 -37
- package/dist/adapters/execution/restate.d.ts +130 -0
- package/dist/adapters/execution/restate.js +442 -0
- package/dist/adapters/media.d.ts +2 -2
- package/dist/adapters/media.js +2 -2
- package/dist/adapters/observe.d.ts +2 -1
- package/dist/adapters/observe.js +2 -3
- package/dist/adapters/sandbox/virtual.js +1 -1
- package/dist/adapters/streams/s2.d.ts +18 -0
- package/dist/adapters/streams/s2.js +361 -0
- package/dist/assets.d.ts +2 -2
- package/dist/assets.js +5 -3
- package/dist/core/executors.d.ts +15 -3
- package/dist/core/executors.js +40 -5
- package/dist/core/flow-host.d.ts +225 -0
- package/dist/core/flow-host.js +518 -0
- package/dist/core/limits.d.ts +40 -0
- package/dist/core/limits.js +76 -0
- package/dist/core/runtime.d.ts +5 -90
- package/dist/core/runtime.js +3 -1447
- package/dist/core/sandbox-routes.d.ts +40 -0
- package/dist/core/sandbox-routes.js +106 -0
- package/dist/core/turn-manifest.d.ts +50 -0
- package/dist/core/turn-manifest.js +66 -0
- package/dist/execution/memory.d.ts +54 -0
- package/dist/execution/memory.js +217 -0
- package/dist/execution/types.d.ts +125 -0
- package/dist/execution/types.js +52 -0
- package/dist/host/config-for.d.ts +17 -0
- package/dist/host/config-for.js +27 -0
- package/dist/host/config.d.ts +16 -0
- package/dist/host/create-host.d.ts +56 -0
- package/dist/host/create-host.js +420 -0
- package/dist/host/environment.d.ts +13 -0
- package/dist/host/environment.js +53 -0
- package/dist/host/execution.d.ts +61 -0
- package/dist/host/execution.js +69 -0
- package/dist/host/http.d.ts +39 -0
- package/dist/host/http.js +142 -0
- package/dist/host/index.d.ts +8 -0
- package/dist/host/index.js +7 -0
- package/dist/host/logger.d.ts +3 -0
- package/dist/host/logger.js +18 -0
- package/dist/host/main.d.ts +3 -0
- package/dist/host/main.js +201 -0
- package/dist/host/stack-config.d.ts +72 -0
- package/dist/host/stack-config.js +193 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +4 -2
- package/dist/infra/database.d.ts +16 -0
- package/dist/infra/database.js +31 -0
- package/dist/infra/execution.d.ts +39 -0
- package/dist/infra/execution.js +68 -0
- package/dist/infra/index.d.ts +42 -0
- package/dist/infra/index.js +56 -0
- package/dist/infra/readiness.d.ts +33 -0
- package/dist/infra/readiness.js +53 -0
- package/dist/infra/streams.d.ts +17 -0
- package/dist/infra/streams.js +33 -0
- package/dist/infra/workers.d.ts +39 -0
- package/dist/infra/workers.js +31 -0
- package/dist/mcp/connect.d.ts +1 -0
- package/dist/mcp/connect.js +2 -0
- package/dist/mcp/pool.d.ts +3 -1
- package/dist/mcp/pool.js +2 -1
- package/dist/mcp/stdio.d.ts +2 -0
- package/dist/mcp/stdio.js +3 -1
- package/dist/model/auth-store.d.ts +1 -1
- package/dist/model/auth-store.js +2 -2
- package/dist/model/models.d.ts +4 -0
- package/dist/model/models.js +22 -26
- package/dist/model/pi-model.d.ts +2 -2
- package/dist/model/pi-model.js +6 -6
- package/dist/model/settings.d.ts +5 -1
- package/dist/model/settings.js +6 -2
- package/dist/sandbox/manager.d.ts +26 -8
- package/dist/sandbox/manager.js +95 -47
- package/dist/sandbox/select.d.ts +5 -4
- package/dist/sandbox/select.js +18 -13
- package/dist/sandbox/share.d.ts +23 -0
- package/dist/sandbox/share.js +58 -0
- package/dist/sandbox/types.d.ts +2 -2
- package/dist/store/canonical.d.ts +5 -0
- package/dist/store/canonical.js +17 -0
- package/dist/store/cursor.d.ts +9 -0
- package/dist/store/cursor.js +18 -0
- package/dist/store/memory.d.ts +57 -0
- package/dist/store/memory.js +643 -0
- package/dist/store/ownership.d.ts +19 -0
- package/dist/store/ownership.js +33 -0
- package/dist/store/postgres/connect.d.ts +25 -0
- package/dist/store/postgres/connect.js +25 -0
- package/dist/store/postgres/locking.d.ts +16 -0
- package/dist/store/postgres/locking.js +22 -0
- package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
- package/dist/store/postgres/migrations/001_initial.js +196 -0
- package/dist/store/postgres/migrations/index.d.ts +49 -0
- package/dist/store/postgres/migrations/index.js +75 -0
- package/dist/store/postgres/names.d.ts +11 -0
- package/dist/store/postgres/names.js +42 -0
- package/dist/store/postgres/store.d.ts +11 -0
- package/dist/store/postgres/store.js +790 -0
- package/dist/store/postgres/tenants.d.ts +90 -0
- package/dist/store/postgres/tenants.js +207 -0
- package/dist/store/types.d.ts +440 -0
- package/dist/store/types.js +10 -0
- package/dist/streams/basin.d.ts +23 -0
- package/dist/streams/basin.js +51 -0
- package/dist/streams/memory.d.ts +25 -0
- package/dist/streams/memory.js +124 -0
- package/dist/streams/relay.d.ts +37 -0
- package/dist/streams/relay.js +254 -0
- package/dist/streams/types.d.ts +115 -0
- package/dist/streams/types.js +86 -0
- package/dist/tenant/actions.d.ts +153 -0
- package/dist/tenant/actions.js +191 -0
- package/dist/tenant/advance.d.ts +15 -0
- package/dist/tenant/advance.js +439 -0
- package/dist/tenant/auth.d.ts +11 -0
- package/dist/tenant/auth.js +34 -0
- package/dist/tenant/commands.d.ts +3 -0
- package/dist/tenant/commands.js +384 -0
- package/dist/tenant/context.d.ts +140 -0
- package/dist/tenant/context.js +40 -0
- package/dist/tenant/effects.d.ts +20 -0
- package/dist/tenant/effects.js +539 -0
- package/dist/tenant/envelope.d.ts +7 -0
- package/dist/tenant/envelope.js +22 -0
- package/dist/tenant/ephemeral.d.ts +42 -0
- package/dist/tenant/ephemeral.js +224 -0
- package/dist/tenant/http.d.ts +32 -0
- package/dist/tenant/http.js +51 -0
- package/dist/tenant/live.d.ts +92 -0
- package/dist/tenant/live.js +290 -0
- package/dist/tenant/logger.d.ts +9 -0
- package/dist/tenant/logger.js +25 -0
- package/dist/tenant/model-setting.d.ts +16 -0
- package/dist/tenant/model-setting.js +12 -0
- package/dist/tenant/module.d.ts +38 -0
- package/dist/tenant/module.js +319 -0
- package/dist/tenant/paths.d.ts +28 -0
- package/dist/tenant/paths.js +91 -0
- package/dist/tenant/pool.d.ts +10 -0
- package/dist/tenant/pool.js +54 -0
- package/dist/tenant/principals.d.ts +13 -0
- package/dist/tenant/principals.js +37 -0
- package/dist/tenant/quarantine-error.d.ts +6 -0
- package/dist/tenant/quarantine-error.js +9 -0
- package/dist/tenant/quarantine.d.ts +27 -0
- package/dist/tenant/quarantine.js +72 -0
- package/dist/tenant/reset.d.ts +22 -0
- package/dist/tenant/reset.js +81 -0
- package/dist/tenant/routes-tenant.d.ts +12 -0
- package/dist/tenant/routes-tenant.js +126 -0
- package/dist/tenant/routes.d.ts +9 -0
- package/dist/tenant/routes.js +142 -0
- package/dist/tenant/runtime.d.ts +81 -0
- package/dist/tenant/runtime.js +298 -0
- package/dist/tenant/scheduler.d.ts +25 -0
- package/dist/tenant/scheduler.js +84 -0
- package/dist/tenant/session.d.ts +60 -0
- package/dist/tenant/session.js +94 -0
- package/dist/tenant/sessions.d.ts +34 -0
- package/dist/tenant/sessions.js +149 -0
- package/dist/tenant/status.d.ts +37 -0
- package/dist/tenant/status.js +130 -0
- package/dist/tenant/store-memory.d.ts +16 -0
- package/dist/tenant/store-memory.js +111 -0
- package/dist/tenant/store-pg.d.ts +15 -0
- package/dist/tenant/store-pg.js +140 -0
- package/dist/tenant/streams.d.ts +126 -0
- package/dist/tenant/streams.js +419 -0
- package/dist/tenant/sweep.d.ts +15 -0
- package/dist/tenant/sweep.js +122 -0
- package/dist/tenant/types.d.ts +205 -0
- package/dist/tenant/types.js +23 -0
- package/dist/tenant/worker.d.ts +84 -0
- package/dist/tenant/worker.js +189 -0
- package/dist/vault/kek.d.ts +5 -2
- package/dist/vault/kek.js +7 -10
- package/dist/vault/service.d.ts +37 -31
- package/dist/vault/service.js +439 -359
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +14 -11
- package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
- package/dist/adapters/sandbox/microsandbox.js +0 -238
- package/dist/core/main.js +0 -35
- package/dist/core/store.d.ts +0 -30
- package/dist/core/store.js +0 -202
- /package/dist/{core/main.d.ts → host/config.js} +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Durable Session Execution seam (architecture §12.3).
|
|
3
|
+
*
|
|
4
|
+
* Durable Session Execution decides when to look at a session again. It never
|
|
5
|
+
* holds session state: every outcome lives in the Session Store, and `advance`
|
|
6
|
+
* is safe to repeat (§10.1). Restate is the supported implementation
|
|
7
|
+
* (`adapters/execution/restate.ts`); `execution/memory.ts` is the in-process
|
|
8
|
+
* implementation used by unit tests, `startEphemeralRuntime` and a Host
|
|
9
|
+
* started without Restate endpoints.
|
|
10
|
+
*
|
|
11
|
+
* ## Guarantees every implementation keeps
|
|
12
|
+
*
|
|
13
|
+
* - **One advance per key at a time.** The key is `<tenantId>:<sessionId>`.
|
|
14
|
+
* Two `advance` calls for one key never overlap, on one process or many.
|
|
15
|
+
* Different keys may run concurrently.
|
|
16
|
+
* - **At least once after a wake.** When `wake` resolves, an `advance` for the
|
|
17
|
+
* key will start after that point (possibly merged with other wakes). Wakes
|
|
18
|
+
* are sent after the Session Store transaction that caused them commits
|
|
19
|
+
* (`Tx.afterCommit`), never inside it.
|
|
20
|
+
* - **Dedupe.** Wakes carrying the same `dedupeKey` for the same key cause at
|
|
21
|
+
* most one advance for that cause (within the implementation's retention
|
|
22
|
+
* window, at least 24 hours for Restate).
|
|
23
|
+
* - **Busy re-wake.** An advance that returns `busy` is re-run for the same key
|
|
24
|
+
* after `retryAfterMs`.
|
|
25
|
+
* - **Retries on infrastructure errors.** A handler that throws is retried with
|
|
26
|
+
* backoff. Session outcomes never throw: a turn that fails, pauses, waits or
|
|
27
|
+
* becomes `uncertain` is settled in the Session Store and `advance` returns
|
|
28
|
+
* `done`.
|
|
29
|
+
* - **Sweeps.** `armSweep` arms one self-re-arming sweep per Tenant; arming an
|
|
30
|
+
* armed Tenant is a no-op. The sweep expires claims, re-wakes orphaned
|
|
31
|
+
* `runnable` sessions and relays outbox rows left behind.
|
|
32
|
+
*/
|
|
33
|
+
export const WAKE_REASONS = [
|
|
34
|
+
"message",
|
|
35
|
+
"approve",
|
|
36
|
+
"respond",
|
|
37
|
+
"action_result",
|
|
38
|
+
"linked",
|
|
39
|
+
"flow",
|
|
40
|
+
"recover",
|
|
41
|
+
];
|
|
42
|
+
/** The key one advance at a time is serialized on. */
|
|
43
|
+
export function sessionKey(tenantId, sessionId) {
|
|
44
|
+
return `${tenantId}:${sessionId}`;
|
|
45
|
+
}
|
|
46
|
+
/** Splits a key made by `sessionKey`. Tenant ids never contain `:`. */
|
|
47
|
+
export function parseSessionKey(key) {
|
|
48
|
+
const at = key.indexOf(":");
|
|
49
|
+
if (at <= 0 || at === key.length - 1)
|
|
50
|
+
throw new Error(`Invalid session key: ${key}`);
|
|
51
|
+
return { tenantId: key.slice(0, at), sessionId: key.slice(at + 1) };
|
|
52
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HostConfigFile } from "./config.js";
|
|
2
|
+
import type { Logger, TenantConfig } from "../tenant/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Builds a TenantConfig for `openTenantRuntime`.
|
|
5
|
+
* The sandbox backend here is the Host default; a Tenant's seeded `sandbox.backend`
|
|
6
|
+
* setting overrides it when the Tenant opens (I1, A18).
|
|
7
|
+
*/
|
|
8
|
+
export declare function configForFactory(options: {
|
|
9
|
+
hostRoot: string;
|
|
10
|
+
hostConfig: HostConfigFile;
|
|
11
|
+
logger: Logger;
|
|
12
|
+
/** Allowlisted baseline from `baselineEnvironment` (built in `host/main.ts`). */
|
|
13
|
+
baseline: Readonly<Record<string, string>>;
|
|
14
|
+
mode?: TenantConfig["mode"];
|
|
15
|
+
/** Override model; default vault. Fixture/scripted require ephemeral/test mode. */
|
|
16
|
+
model?: TenantConfig["model"];
|
|
17
|
+
}): (id: string) => TenantConfig;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { tenantPaths } from "../tenant/paths.js";
|
|
2
|
+
import { tenantChildEnvironment } from "./environment.js";
|
|
3
|
+
/**
|
|
4
|
+
* Builds a TenantConfig for `openTenantRuntime`.
|
|
5
|
+
* The sandbox backend here is the Host default; a Tenant's seeded `sandbox.backend`
|
|
6
|
+
* setting overrides it when the Tenant opens (I1, A18).
|
|
7
|
+
*/
|
|
8
|
+
export function configForFactory(options) {
|
|
9
|
+
const { baseline } = options;
|
|
10
|
+
return (id) => {
|
|
11
|
+
const tenant = tenantPaths(options.hostRoot, id);
|
|
12
|
+
const sandboxBackend = "auto";
|
|
13
|
+
return {
|
|
14
|
+
tenantId: id,
|
|
15
|
+
mode: options.mode ?? "shared",
|
|
16
|
+
paths: tenant,
|
|
17
|
+
sandbox: { backend: sandboxBackend },
|
|
18
|
+
model: options.model ?? { kind: "vault" },
|
|
19
|
+
childEnv: tenantChildEnvironment(baseline, options.hostConfig, tenant),
|
|
20
|
+
logger: {
|
|
21
|
+
info: (message, fields) => options.logger.info(message, { tenantId: id, ...fields }),
|
|
22
|
+
warn: (message, fields) => options.logger.warn(message, { tenantId: id, ...fields }),
|
|
23
|
+
error: (message, fields) => options.logger.error(message, { tenantId: id, ...fields }),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** host.json — durable, no secrets */
|
|
2
|
+
export interface HostConfigFile {
|
|
3
|
+
hostId: string;
|
|
4
|
+
host: string;
|
|
5
|
+
port: number;
|
|
6
|
+
allowNonLoopback?: boolean;
|
|
7
|
+
proxy?: {
|
|
8
|
+
httpsProxy?: string;
|
|
9
|
+
noProxy?: string;
|
|
10
|
+
nodeExtraCaCerts?: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** host-credentials.json, mode 0600 */
|
|
14
|
+
export interface HostCredentialsFile {
|
|
15
|
+
adminKey: string;
|
|
16
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Logger, type TenantModule } from "../tenant/types.js";
|
|
2
|
+
import type { HostConfigFile, HostCredentialsFile } from "./config.js";
|
|
3
|
+
import type { ContainerListen } from "./stack-config.js";
|
|
4
|
+
export interface CreateHostOptions {
|
|
5
|
+
hostRoot: string;
|
|
6
|
+
module: TenantModule;
|
|
7
|
+
config: HostConfigFile;
|
|
8
|
+
credentials: HostCredentialsFile;
|
|
9
|
+
logger: Logger;
|
|
10
|
+
/** Diagnostic package version of `@nylorun/core` for `/health`. */
|
|
11
|
+
coreVersion: string;
|
|
12
|
+
/** Process id reported by `/health` and admin host status. Defaults to `process.pid`. */
|
|
13
|
+
pid?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Container mode: bind this address and port instead of host.json's, and
|
|
16
|
+
* accept only `allowedHosts` in the `Host` check (replacing the
|
|
17
|
+
* loopback-only rule). host.json then describes the client-facing address.
|
|
18
|
+
*/
|
|
19
|
+
listen?: ContainerListen;
|
|
20
|
+
/**
|
|
21
|
+
* The client-facing URL `/v1/admin/status` reports as `host.url`
|
|
22
|
+
* (`NYLORUN_PUBLIC_URL`). Defaults to the bound address, which in container
|
|
23
|
+
* mode is `http://0.0.0.0:4000`.
|
|
24
|
+
*/
|
|
25
|
+
publicUrl?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Infrastructure readiness (`infra/readiness.ts`). `/ready` adds its checks
|
|
28
|
+
* and answers 503 while it reports not ok. Default: listener and discovery
|
|
29
|
+
* only.
|
|
30
|
+
*/
|
|
31
|
+
readiness?: () => Promise<{
|
|
32
|
+
ok: boolean;
|
|
33
|
+
checks: Record<string, boolean>;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Shutdown steps around closing the Tenants. `close()` runs them whatever asked for it
|
|
37
|
+
* (SIGTERM in `host/main.ts`, `POST /v1/admin/host/shutdown`): the listener stops, then
|
|
38
|
+
* `beforeTenants` (stop the Worker so no advance starts on a closing Tenant), the Tenants
|
|
39
|
+
* close, then `afterTenants` (end the infrastructure clients). A failing step is logged
|
|
40
|
+
* and shutdown goes on.
|
|
41
|
+
*/
|
|
42
|
+
shutdown?: {
|
|
43
|
+
beforeTenants?(): Promise<void>;
|
|
44
|
+
afterTenants?(): Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export interface HostServer {
|
|
48
|
+
listen(): Promise<void>;
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
/** Settles once `close()` has finished, whatever called it. */
|
|
51
|
+
readonly closed: Promise<void>;
|
|
52
|
+
readonly url: string;
|
|
53
|
+
}
|
|
54
|
+
/** Constant-time comparison of SHA-256 digests of the presented and stored admin keys. */
|
|
55
|
+
export declare function adminKeyMatches(presented: string | undefined, adminKey: string): boolean;
|
|
56
|
+
export declare function createHost(options: CreateHostOptions): HostServer;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { checkCompatibility, HOST_PROTOCOL, isTenantId, PROTOCOL_HEADER, TENANT_HEADER, } from "@nylorun/core/compatibility";
|
|
4
|
+
import { AdminStatusSchema, CreateTenantRequestSchema, } from "@nylorun/core/contracts";
|
|
5
|
+
import { TenantBusyError, TenantConflictError, } from "../tenant/quarantine.js";
|
|
6
|
+
import { TenantNotFoundError, } from "../tenant/types.js";
|
|
7
|
+
import { EXIT_NON_LOOPBACK, EXIT_PORT_IN_USE, HostListenError, isAllowedRequestHost, isJsonContentType, isLoopbackHost, readBearer, readJsonBody, redactRoutePath, requestHasBody, sendJson, sendOpaqueNotFound, sendProtocolRejected, sendRejected, } from "./http.js";
|
|
8
|
+
import { RUNTIME_VERSION } from "../version.js";
|
|
9
|
+
function hashUtf8(value) {
|
|
10
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
11
|
+
}
|
|
12
|
+
/** Constant-time comparison of SHA-256 digests of the presented and stored admin keys. */
|
|
13
|
+
export function adminKeyMatches(presented, adminKey) {
|
|
14
|
+
if (presented === undefined)
|
|
15
|
+
return false;
|
|
16
|
+
const a = hashUtf8(presented);
|
|
17
|
+
const b = hashUtf8(adminKey);
|
|
18
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
19
|
+
}
|
|
20
|
+
function headerValue(headers, name) {
|
|
21
|
+
const raw = headers[name.toLowerCase()];
|
|
22
|
+
if (Array.isArray(raw))
|
|
23
|
+
return raw[0];
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
function parseProtocolVersion(raw) {
|
|
27
|
+
if (raw === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
if (!/^\d+$/.test(trimmed))
|
|
31
|
+
return undefined;
|
|
32
|
+
return Number(trimmed);
|
|
33
|
+
}
|
|
34
|
+
function protocolAccepted(raw) {
|
|
35
|
+
const version = parseProtocolVersion(raw);
|
|
36
|
+
if (version === undefined)
|
|
37
|
+
return false;
|
|
38
|
+
return checkCompatibility({ version, required: [] }, HOST_PROTOCOL).ok;
|
|
39
|
+
}
|
|
40
|
+
export function createHost(options) {
|
|
41
|
+
const { hostRoot, module, config, credentials, logger, coreVersion, } = options;
|
|
42
|
+
const pid = options.pid ?? process.pid;
|
|
43
|
+
const containerListen = options.listen;
|
|
44
|
+
const bindHost = containerListen?.host ?? config.host;
|
|
45
|
+
const bindPort = containerListen?.port ?? config.port;
|
|
46
|
+
let server;
|
|
47
|
+
let url = "";
|
|
48
|
+
let listenPort = bindPort;
|
|
49
|
+
let closing = false;
|
|
50
|
+
let closePromise;
|
|
51
|
+
const adminStatusBody = async () => {
|
|
52
|
+
const tenants = await module.list();
|
|
53
|
+
const aggregate = await module.summarize();
|
|
54
|
+
return AdminStatusSchema.parse({
|
|
55
|
+
service: "nylorun-runtime",
|
|
56
|
+
version: RUNTIME_VERSION,
|
|
57
|
+
protocol: {
|
|
58
|
+
min: HOST_PROTOCOL.min,
|
|
59
|
+
max: HOST_PROTOCOL.max,
|
|
60
|
+
features: [...HOST_PROTOCOL.features],
|
|
61
|
+
},
|
|
62
|
+
tenants,
|
|
63
|
+
aggregate,
|
|
64
|
+
host: {
|
|
65
|
+
hostId: config.hostId,
|
|
66
|
+
url: options.publicUrl ?? url,
|
|
67
|
+
pid,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const requireAdmin = (request, response) => {
|
|
72
|
+
const token = readBearer(headerValue(request.headers, "authorization"));
|
|
73
|
+
if (!adminKeyMatches(token, credentials.adminKey)) {
|
|
74
|
+
sendOpaqueNotFound(response);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
};
|
|
79
|
+
const handleAdmin = async (request, response, urlObj, segments) => {
|
|
80
|
+
const method = request.method ?? "GET";
|
|
81
|
+
// /v1/admin/...
|
|
82
|
+
if (segments[2] === "tenants") {
|
|
83
|
+
if (segments.length === 3 && method === "GET") {
|
|
84
|
+
const tenants = await module.list();
|
|
85
|
+
return sendJson(response, 200, tenants);
|
|
86
|
+
}
|
|
87
|
+
if (segments.length === 3 && method === "POST") {
|
|
88
|
+
const body = CreateTenantRequestSchema.parse(await readJsonBody(request));
|
|
89
|
+
try {
|
|
90
|
+
const result = await module.create({
|
|
91
|
+
tenantId: body.tenantId,
|
|
92
|
+
name: body.name,
|
|
93
|
+
principalId: body.principalId,
|
|
94
|
+
credentialHash: body.credentialHash,
|
|
95
|
+
idempotencyKey: body.idempotencyKey,
|
|
96
|
+
...(body.studioCredentialHash
|
|
97
|
+
? { studioCredentialHash: body.studioCredentialHash }
|
|
98
|
+
: {}),
|
|
99
|
+
});
|
|
100
|
+
return sendJson(response, result.created ? 201 : 200, result.envelope);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const code = error.code;
|
|
104
|
+
if (error instanceof TenantConflictError ||
|
|
105
|
+
error.name === "TenantConflictError" ||
|
|
106
|
+
code === "conflict" ||
|
|
107
|
+
code === "tenant_conflict") {
|
|
108
|
+
return sendRejected(response, 409, "tenant_conflict", error instanceof Error ? error.message : "Tenant conflict");
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (segments.length === 4 && segments[3]) {
|
|
114
|
+
const id = segments[3];
|
|
115
|
+
if (method === "GET") {
|
|
116
|
+
const status = await module.status(id);
|
|
117
|
+
if (!status)
|
|
118
|
+
return sendOpaqueNotFound(response);
|
|
119
|
+
return sendJson(response, 200, status);
|
|
120
|
+
}
|
|
121
|
+
if (method === "DELETE") {
|
|
122
|
+
const activeWork = urlObj.searchParams.get("activeWork") ?? "refuse";
|
|
123
|
+
if (activeWork !== "refuse" &&
|
|
124
|
+
activeWork !== "drain" &&
|
|
125
|
+
activeWork !== "cancel") {
|
|
126
|
+
return sendJson(response, 400, {
|
|
127
|
+
status: "rejected",
|
|
128
|
+
code: "invalid_request",
|
|
129
|
+
message: "activeWork must be refuse, drain, or cancel",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
await module.delete(id, activeWork);
|
|
134
|
+
response.writeHead(204);
|
|
135
|
+
response.end();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof TenantNotFoundError)
|
|
140
|
+
return sendOpaqueNotFound(response);
|
|
141
|
+
const code = error.code;
|
|
142
|
+
if (error instanceof TenantBusyError ||
|
|
143
|
+
error.name === "TenantBusyError" ||
|
|
144
|
+
code === "active_work" ||
|
|
145
|
+
code === "conflict") {
|
|
146
|
+
return sendRejected(response, 409, "active_work", error instanceof Error ? error.message : "Active work");
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// D12: /v1/admin/status and /v1/admin/host share one handler.
|
|
154
|
+
if ((segments[2] === "host" || segments[2] === "status") &&
|
|
155
|
+
segments.length === 3 &&
|
|
156
|
+
method === "GET") {
|
|
157
|
+
return sendJson(response, 200, await adminStatusBody());
|
|
158
|
+
}
|
|
159
|
+
if (segments[2] === "host" &&
|
|
160
|
+
segments.length === 4 &&
|
|
161
|
+
segments[3] === "shutdown" &&
|
|
162
|
+
method === "POST") {
|
|
163
|
+
sendJson(response, 200, { status: "shutting_down" });
|
|
164
|
+
void close();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
return sendRejected(response, 404, "not_found", "Route not found");
|
|
168
|
+
};
|
|
169
|
+
const handle = async (request, response) => {
|
|
170
|
+
const started = Date.now();
|
|
171
|
+
let tenantId;
|
|
172
|
+
let statusCode = 500;
|
|
173
|
+
try {
|
|
174
|
+
const urlObj = new URL(request.url ?? "/", "http://runtime.local");
|
|
175
|
+
const pathname = urlObj.pathname;
|
|
176
|
+
// D§11: Host, Origin, then Content-Type — before any other processing.
|
|
177
|
+
const hostHeader = headerValue(request.headers, "host");
|
|
178
|
+
if (!isAllowedRequestHost(hostHeader, {
|
|
179
|
+
port: listenPort,
|
|
180
|
+
host: config.host,
|
|
181
|
+
allowNonLoopback: config.allowNonLoopback,
|
|
182
|
+
...(containerListen
|
|
183
|
+
? { allowedHosts: containerListen.allowedHosts }
|
|
184
|
+
: {}),
|
|
185
|
+
})) {
|
|
186
|
+
sendRejected(response, 421, "host_rejected", "Host header is not an allowed loopback or configured address");
|
|
187
|
+
statusCode = 421;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (headerValue(request.headers, "origin") !== undefined) {
|
|
191
|
+
sendRejected(response, 403, "origin_rejected", "Browser Origin headers are not accepted");
|
|
192
|
+
statusCode = 403;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (requestHasBody(request) &&
|
|
196
|
+
!isJsonContentType(headerValue(request.headers, "content-type"))) {
|
|
197
|
+
sendRejected(response, 415, "unsupported_media_type", "Request bodies must use application/json");
|
|
198
|
+
statusCode = 415;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (pathname === "/health") {
|
|
202
|
+
sendJson(response, 200, {
|
|
203
|
+
status: "ok",
|
|
204
|
+
service: "nylorun-runtime",
|
|
205
|
+
version: RUNTIME_VERSION,
|
|
206
|
+
protocol: {
|
|
207
|
+
min: HOST_PROTOCOL.min,
|
|
208
|
+
max: HOST_PROTOCOL.max,
|
|
209
|
+
features: [...HOST_PROTOCOL.features],
|
|
210
|
+
},
|
|
211
|
+
coreVersion,
|
|
212
|
+
hostId: config.hostId,
|
|
213
|
+
pid,
|
|
214
|
+
});
|
|
215
|
+
statusCode = 200;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (pathname === "/ready") {
|
|
219
|
+
const listener = Boolean(server?.listening);
|
|
220
|
+
const discovery = module.started;
|
|
221
|
+
const infra = await options.readiness?.();
|
|
222
|
+
const ready = listener && discovery && !closing && (infra?.ok ?? true);
|
|
223
|
+
sendJson(response, ready ? 200 : 503, {
|
|
224
|
+
status: ready ? "ready" : "not_ready",
|
|
225
|
+
service: "nylorun-runtime",
|
|
226
|
+
checks: { listener, discovery, ...infra?.checks },
|
|
227
|
+
});
|
|
228
|
+
statusCode = ready ? 200 : 503;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
232
|
+
const isAdmin = segments[0] === "v1" && segments[1] === "admin";
|
|
233
|
+
if (isAdmin) {
|
|
234
|
+
const protocolHeader = headerValue(request.headers, PROTOCOL_HEADER);
|
|
235
|
+
if (!protocolAccepted(protocolHeader)) {
|
|
236
|
+
sendProtocolRejected(response);
|
|
237
|
+
statusCode = 426;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!requireAdmin(request, response)) {
|
|
241
|
+
statusCode = 404;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await handleAdmin(request, response, urlObj, segments);
|
|
245
|
+
statusCode = response.statusCode || 200;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Tenant-scoped routes: header pattern → protocol → resolve → Tenant handle.
|
|
249
|
+
const tenantHeader = headerValue(request.headers, TENANT_HEADER);
|
|
250
|
+
if (tenantHeader === undefined || tenantHeader.trim() === "") {
|
|
251
|
+
sendJson(response, 400, {
|
|
252
|
+
status: "rejected",
|
|
253
|
+
code: "invalid_request",
|
|
254
|
+
message: `${TENANT_HEADER} header is required`,
|
|
255
|
+
});
|
|
256
|
+
statusCode = 400;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!isTenantId(tenantHeader)) {
|
|
260
|
+
sendJson(response, 400, {
|
|
261
|
+
status: "rejected",
|
|
262
|
+
code: "invalid_request",
|
|
263
|
+
message: `${TENANT_HEADER} header is malformed`,
|
|
264
|
+
});
|
|
265
|
+
statusCode = 400;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
tenantId = tenantHeader;
|
|
269
|
+
const protocolHeader = headerValue(request.headers, PROTOCOL_HEADER);
|
|
270
|
+
if (!protocolAccepted(protocolHeader)) {
|
|
271
|
+
sendProtocolRejected(response);
|
|
272
|
+
statusCode = 426;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const resolution = await module.resolve(tenantId);
|
|
276
|
+
if (resolution.kind !== "open") {
|
|
277
|
+
if (resolution.kind === "quarantined") {
|
|
278
|
+
logger.warn("tenant_quarantined", {
|
|
279
|
+
tenantId,
|
|
280
|
+
code: resolution.quarantine.code,
|
|
281
|
+
repair: resolution.quarantine.repair,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
sendOpaqueNotFound(response);
|
|
285
|
+
statusCode = 404;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
await resolution.handle.handle(request, response, urlObj);
|
|
289
|
+
statusCode = response.statusCode || 200;
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
const status = error.status;
|
|
293
|
+
if (typeof status === "number" && status >= 400 && status < 600) {
|
|
294
|
+
sendJson(response, status, {
|
|
295
|
+
status: "rejected",
|
|
296
|
+
code: status === 400 ? "invalid_request" : "request_rejected",
|
|
297
|
+
message: error instanceof Error ? error.message : "Request rejected",
|
|
298
|
+
});
|
|
299
|
+
statusCode = status;
|
|
300
|
+
}
|
|
301
|
+
else if (error &&
|
|
302
|
+
typeof error === "object" &&
|
|
303
|
+
"name" in error &&
|
|
304
|
+
error.name === "ZodError") {
|
|
305
|
+
sendJson(response, 400, {
|
|
306
|
+
status: "rejected",
|
|
307
|
+
code: "invalid_request",
|
|
308
|
+
message: "Invalid request body",
|
|
309
|
+
});
|
|
310
|
+
statusCode = 400;
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
logger.error("request_failed", {
|
|
314
|
+
error: error instanceof Error ? error.message : String(error),
|
|
315
|
+
});
|
|
316
|
+
if (!response.headersSent) {
|
|
317
|
+
sendJson(response, 500, {
|
|
318
|
+
status: "rejected",
|
|
319
|
+
code: "internal_error",
|
|
320
|
+
message: "Internal error",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
statusCode = 500;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
if (pathnameIsLogged(request.url)) {
|
|
328
|
+
logger.info("request", {
|
|
329
|
+
status: statusCode,
|
|
330
|
+
tenantId,
|
|
331
|
+
durationMs: Date.now() - started,
|
|
332
|
+
path: redactRoutePath(new URL(request.url ?? "/", "http://runtime.local").pathname),
|
|
333
|
+
method: request.method,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
async function listen() {
|
|
339
|
+
if (server)
|
|
340
|
+
throw new Error("Already listening");
|
|
341
|
+
// Container mode binds a non-loopback address behind an explicit Host
|
|
342
|
+
// allowlist; otherwise only loopback unless host.json allows more.
|
|
343
|
+
if (!containerListen &&
|
|
344
|
+
!config.allowNonLoopback &&
|
|
345
|
+
!isLoopbackHost(config.host)) {
|
|
346
|
+
throw new HostListenError(`Refusing to bind non-loopback host ${config.host} without allowNonLoopback`, EXIT_NON_LOOPBACK);
|
|
347
|
+
}
|
|
348
|
+
server = createServer((req, res) => {
|
|
349
|
+
void handle(req, res);
|
|
350
|
+
});
|
|
351
|
+
await new Promise((resolve, reject) => {
|
|
352
|
+
server.once("error", (error) => {
|
|
353
|
+
if (error.code === "EADDRINUSE") {
|
|
354
|
+
reject(new HostListenError(`Port ${bindPort} on ${bindHost} is already in use`, EXIT_PORT_IN_USE, error));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
reject(error);
|
|
358
|
+
});
|
|
359
|
+
server.listen(bindPort, bindHost, resolve);
|
|
360
|
+
});
|
|
361
|
+
const address = server.address();
|
|
362
|
+
listenPort =
|
|
363
|
+
typeof address === "object" && address ? address.port : bindPort;
|
|
364
|
+
url = `http://${bindHost}:${listenPort}`;
|
|
365
|
+
await module.start();
|
|
366
|
+
logger.info("host_listening", {
|
|
367
|
+
hostId: config.hostId,
|
|
368
|
+
url,
|
|
369
|
+
pid,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const step = async (name, run) => {
|
|
373
|
+
try {
|
|
374
|
+
await run?.();
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
logger.error("host_shutdown_step_failed", {
|
|
378
|
+
step: name,
|
|
379
|
+
error: error instanceof Error ? error.message : String(error),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
let settleClosed;
|
|
384
|
+
const closed = new Promise((resolve) => {
|
|
385
|
+
settleClosed = resolve;
|
|
386
|
+
});
|
|
387
|
+
async function close() {
|
|
388
|
+
if (closePromise)
|
|
389
|
+
return closePromise;
|
|
390
|
+
closePromise = (async () => {
|
|
391
|
+
closing = true;
|
|
392
|
+
logger.info("host_shutdown", { hostId: config.hostId });
|
|
393
|
+
if (server) {
|
|
394
|
+
await new Promise((resolve) => {
|
|
395
|
+
server.close(() => resolve());
|
|
396
|
+
server.closeIdleConnections?.();
|
|
397
|
+
});
|
|
398
|
+
server = undefined;
|
|
399
|
+
}
|
|
400
|
+
await step("beforeTenants", options.shutdown?.beforeTenants);
|
|
401
|
+
await step("tenants", () => module.close());
|
|
402
|
+
await step("afterTenants", options.shutdown?.afterTenants);
|
|
403
|
+
})().finally(settleClosed);
|
|
404
|
+
return closePromise;
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
listen,
|
|
408
|
+
close,
|
|
409
|
+
closed,
|
|
410
|
+
get url() {
|
|
411
|
+
return url;
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function pathnameIsLogged(rawUrl) {
|
|
416
|
+
if (!rawUrl)
|
|
417
|
+
return true;
|
|
418
|
+
const pathname = new URL(rawUrl, "http://runtime.local").pathname;
|
|
419
|
+
return pathname !== "/health" && pathname !== "/ready";
|
|
420
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HostConfigFile } from "./config.js";
|
|
2
|
+
import type { HostPaths } from "../tenant/paths.js";
|
|
3
|
+
import type { TenantPaths } from "../tenant/types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Allowlisted ambient keys for Host and Tenant child processes.
|
|
6
|
+
* `NODE_OPTIONS` is never copied.
|
|
7
|
+
*/
|
|
8
|
+
export declare function baselineEnvironment(env: NodeJS.ProcessEnv | Readonly<Record<string, string | undefined>>): Record<string, string>;
|
|
9
|
+
export type HostProcessPathInputs = Pick<HostPaths, "root" | "home" | "tmp">;
|
|
10
|
+
/** Environment for the Runtime Host process itself (CLI spawn / `runtime run`). */
|
|
11
|
+
export declare function hostProcessEnvironment(baseline: Readonly<Record<string, string>>, hostConfig: HostConfigFile, paths: HostProcessPathInputs): Record<string, string>;
|
|
12
|
+
/** Environment for Tenant-owned children (MCP stdio, sandboxes). */
|
|
13
|
+
export declare function tenantChildEnvironment(baseline: Readonly<Record<string, string>>, hostConfig: HostConfigFile, tenantPaths: TenantPaths): Record<string, string>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const BASELINE_KEYS = new Set(["PATH", "LANG", "TZ"]);
|
|
2
|
+
function isBaselineKey(key) {
|
|
3
|
+
return BASELINE_KEYS.has(key) || key.startsWith("LC_");
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Allowlisted ambient keys for Host and Tenant child processes.
|
|
7
|
+
* `NODE_OPTIONS` is never copied.
|
|
8
|
+
*/
|
|
9
|
+
export function baselineEnvironment(env) {
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const [key, value] of Object.entries(env)) {
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
continue;
|
|
14
|
+
if (key === "NODE_OPTIONS")
|
|
15
|
+
continue;
|
|
16
|
+
if (!isBaselineKey(key))
|
|
17
|
+
continue;
|
|
18
|
+
out[key] = value;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
function proxyEnv(hostConfig) {
|
|
23
|
+
const out = {};
|
|
24
|
+
const proxy = hostConfig.proxy;
|
|
25
|
+
if (!proxy)
|
|
26
|
+
return out;
|
|
27
|
+
if (proxy.httpsProxy)
|
|
28
|
+
out.HTTPS_PROXY = proxy.httpsProxy;
|
|
29
|
+
if (proxy.noProxy)
|
|
30
|
+
out.NO_PROXY = proxy.noProxy;
|
|
31
|
+
if (proxy.nodeExtraCaCerts)
|
|
32
|
+
out.NODE_EXTRA_CA_CERTS = proxy.nodeExtraCaCerts;
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** Environment for the Runtime Host process itself (CLI spawn / `runtime run`). */
|
|
36
|
+
export function hostProcessEnvironment(baseline, hostConfig, paths) {
|
|
37
|
+
return {
|
|
38
|
+
...baseline,
|
|
39
|
+
...proxyEnv(hostConfig),
|
|
40
|
+
HOME: paths.home,
|
|
41
|
+
TMPDIR: paths.tmp,
|
|
42
|
+
NYLORUN_HOME: paths.root,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Environment for Tenant-owned children (MCP stdio, sandboxes). */
|
|
46
|
+
export function tenantChildEnvironment(baseline, hostConfig, tenantPaths) {
|
|
47
|
+
return {
|
|
48
|
+
...baseline,
|
|
49
|
+
...proxyEnv(hostConfig),
|
|
50
|
+
HOME: tenantPaths.home,
|
|
51
|
+
TMPDIR: tenantPaths.tmp,
|
|
52
|
+
};
|
|
53
|
+
}
|