@nylorun/runtime 0.21.0-beta → 0.22.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 +41 -0
- package/README.md +4 -2
- package/dist/adapters/execution/restate.js +26 -1
- package/dist/adapters/sandbox/local.js +1 -1
- package/dist/adapters/sandbox/virtual.js +1 -1
- package/dist/api/components.d.ts +42 -2
- package/dist/core/flow-host.d.ts +2 -3
- package/dist/core/flow-host.js +10 -7
- package/dist/execution/types.d.ts +13 -5
- package/dist/execution/types.js +13 -5
- package/dist/gates/egress-credentials.d.ts +48 -0
- package/dist/gates/egress-credentials.js +147 -0
- package/dist/gates/egress.d.ts +3 -0
- package/dist/gates/egress.js +14 -1
- package/dist/gates/tenant-vaults.d.ts +4 -0
- package/dist/gates/tenant-vaults.js +2 -0
- package/dist/harness/calls.d.ts +2 -0
- package/dist/harness/calls.js +1 -0
- package/dist/harness/executors.js +1 -0
- package/dist/harness/main.js +4 -1
- package/dist/harness/pod.d.ts +2 -0
- package/dist/harness/pod.js +36 -1
- package/dist/harness-api/start.d.ts +2 -0
- package/dist/harness-api/start.js +4 -2
- package/dist/harness-api/ws-server.js +2 -2
- package/dist/host/create-host.d.ts +1 -1
- package/dist/host/create-host.js +2 -1
- package/dist/host/gates.js +11 -0
- package/dist/host/http.d.ts +0 -1
- package/dist/host/http.js +0 -6
- package/dist/host/stack-config.d.ts +2 -0
- package/dist/host/stack-config.js +42 -73
- package/dist/keys/egress-ca.d.ts +15 -0
- package/dist/keys/egress-ca.js +66 -0
- package/dist/keys/keys.d.ts +8 -1
- package/dist/keys/keys.js +6 -0
- package/dist/keys/x509.d.ts +29 -0
- package/dist/keys/x509.js +160 -0
- package/dist/management-openapi.json +195 -9
- package/dist/openapi.json +2 -1
- package/dist/sandbox/join.d.ts +2 -0
- package/dist/sandbox/join.js +8 -3
- package/dist/sandbox/manager.d.ts +2 -0
- package/dist/sandbox/manager.js +1 -1
- package/dist/sandbox/tools.d.ts +3 -1
- package/dist/sandbox/tools.js +4 -2
- package/dist/sandbox/types.d.ts +5 -0
- package/dist/store/postgres/drizzle/0017_wake_outbox.sql +9 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
- package/dist/store/postgres/schema.d.ts +105 -2
- package/dist/store/postgres/schema.js +17 -1
- package/dist/store/postgres/store.js +54 -4
- package/dist/store/types.d.ts +46 -5
- package/dist/tenant/advance.js +1 -4
- package/dist/tenant/commands.js +5 -5
- package/dist/tenant/context.d.ts +10 -7
- package/dist/tenant/runtime.js +2 -1
- package/dist/tenant/sessions.js +26 -0
- package/dist/tenant/sweep.d.ts +14 -0
- package/dist/tenant/sweep.js +69 -10
- package/dist/tenant/transcript.d.ts +8 -8
- package/dist/vault/service.d.ts +34 -0
- package/dist/vault/service.js +148 -15
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/dist/harness/calls.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface ToolRouting {
|
|
|
27
27
|
/** The session that owns the tree's sandbox, and the sandbox resource it is attached to. */
|
|
28
28
|
readonly sandboxOwnerId: string;
|
|
29
29
|
readonly sandboxId?: string;
|
|
30
|
+
/** Variables every sandbox command gets (R2c): secret names set to the sentinel, plain values. */
|
|
31
|
+
readonly environment?: Readonly<Record<string, string>>;
|
|
30
32
|
readonly activeTurnId: string | null;
|
|
31
33
|
/** Who the sandbox call's events are claimed for: the run's id. */
|
|
32
34
|
readonly claim?: string;
|
package/dist/harness/calls.js
CHANGED
|
@@ -127,6 +127,7 @@ export async function callSandboxTool(sandbox, routing, request, signal) {
|
|
|
127
127
|
activeTurnId: routing.activeTurnId,
|
|
128
128
|
manifest: routing.rootManifest,
|
|
129
129
|
...(routing.sandboxId === undefined ? {} : { sandboxId: routing.sandboxId }),
|
|
130
|
+
...(routing.environment === undefined ? {} : { environment: routing.environment }),
|
|
130
131
|
...(routing.claim === undefined ? {} : { claim: routing.claim }),
|
|
131
132
|
}, capability, request.toolName, request.input, signal);
|
|
132
133
|
}
|
|
@@ -95,6 +95,7 @@ export function routingOf(run) {
|
|
|
95
95
|
...(routing.mcpSnapshot ? { mcpSnapshot: routing.mcpSnapshot } : {}),
|
|
96
96
|
sandboxOwnerId: routing.sandbox?.ownerId ?? run.grant.sessionId,
|
|
97
97
|
...(routing.sandbox?.sandboxId === undefined ? {} : { sandboxId: routing.sandbox.sandboxId }),
|
|
98
|
+
...(routing.sandbox?.environment === undefined ? {} : { environment: routing.sandbox.environment }),
|
|
98
99
|
activeTurnId: run.grant.turnId,
|
|
99
100
|
claim: run.runId,
|
|
100
101
|
};
|
package/dist/harness/main.js
CHANGED
|
@@ -44,7 +44,10 @@ export async function runHarness(stack, baseline) {
|
|
|
44
44
|
log: (message, fields) => logger.info(message, fields),
|
|
45
45
|
});
|
|
46
46
|
logger.info("sandbox_network_policy_in_force", { sandboxId: config.pod.sandboxId, waitedMs });
|
|
47
|
-
pod = podHost(config.pod, logger, {
|
|
47
|
+
pod = podHost(config.pod, logger, {
|
|
48
|
+
volumeFile: join(config.root, "volume-id"),
|
|
49
|
+
caBundleFile: join(config.root, "egress-ca-bundle.pem"),
|
|
50
|
+
});
|
|
48
51
|
}
|
|
49
52
|
const token = pod ? () => pod.token() : config.token;
|
|
50
53
|
if (token === undefined)
|
package/dist/harness/pod.d.ts
CHANGED
|
@@ -18,4 +18,6 @@ export declare function podHost(config: PodHostConfig, logger: {
|
|
|
18
18
|
renewMs?: number;
|
|
19
19
|
/** Where the engine keeps the volume's id (on the volume): a new volume gets a new one. */
|
|
20
20
|
volumeFile?: string;
|
|
21
|
+
/** Where the engine writes the trust bundle: the public roots and the egress CA (R2c). */
|
|
22
|
+
caBundleFile?: string;
|
|
21
23
|
}): PodHost;
|
package/dist/harness/pod.js
CHANGED
|
@@ -5,17 +5,34 @@
|
|
|
5
5
|
* read afresh each time: a reset writes a new one). The egress token minted with the host token
|
|
6
6
|
* is the password of the proxy every command gets (`proxyEnv`); both are renewed every
|
|
7
7
|
* `RENEW_MS`, well inside their 15-minute life.
|
|
8
|
+
*
|
|
9
|
+
* The join answer also carries the installation's egress CA (R2c, D50). The engine writes it,
|
|
10
|
+
* after the public roots, to a trust bundle on the pod (`caBundleFile`) and points every
|
|
11
|
+
* command's trust variables at it, so a CLI trusts the leaf egress-gate presents for a host an
|
|
12
|
+
* `environment_secret` is bound to. The bundle is rewritten only when the CA changes.
|
|
8
13
|
*/
|
|
9
14
|
import { randomUUID } from "node:crypto";
|
|
10
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
15
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
16
|
+
import { rootCertificates } from "node:tls";
|
|
11
17
|
import { dirname } from "node:path";
|
|
12
18
|
/** How often the host and egress tokens are renewed. */
|
|
13
19
|
export const RENEW_MS = 5 * 60_000;
|
|
20
|
+
/** The variables CLIs read their trust bundle from: OpenSSL, Node, Python, curl, git, AWS. */
|
|
21
|
+
const TRUST_VARIABLES = [
|
|
22
|
+
"SSL_CERT_FILE",
|
|
23
|
+
"NODE_EXTRA_CA_CERTS",
|
|
24
|
+
"REQUESTS_CA_BUNDLE",
|
|
25
|
+
"CURL_CA_BUNDLE",
|
|
26
|
+
"GIT_SSL_CAINFO",
|
|
27
|
+
"AWS_CA_BUNDLE",
|
|
28
|
+
];
|
|
14
29
|
class Refused extends Error {
|
|
15
30
|
}
|
|
16
31
|
export function podHost(config, logger, options = {}) {
|
|
17
32
|
const call = options.fetch ?? fetch;
|
|
18
33
|
let held;
|
|
34
|
+
/** The CA in the bundle on disk, once one is written. */
|
|
35
|
+
let bundled;
|
|
19
36
|
const post = async (path, init) => {
|
|
20
37
|
const response = await call(`${config.httpUrl}/nylorun/harness/v1/host/${path}`, {
|
|
21
38
|
method: "POST",
|
|
@@ -32,13 +49,27 @@ export function podHost(config, logger, options = {}) {
|
|
|
32
49
|
if (!response.ok)
|
|
33
50
|
throw new Error(`host/${path} answered ${response.status}`);
|
|
34
51
|
const body = JSON.parse(text);
|
|
52
|
+
if (typeof body.caCertificate === "string")
|
|
53
|
+
await writeBundle(body.caCertificate);
|
|
35
54
|
return {
|
|
36
55
|
hostToken: body.hostToken,
|
|
37
56
|
egressToken: body.egressToken,
|
|
38
57
|
epoch: body.epoch,
|
|
39
58
|
expiresAt: Date.parse(body.expiresAt),
|
|
59
|
+
...(typeof body.caCertificate === "string" ? { caCertificate: body.caCertificate } : {}),
|
|
40
60
|
};
|
|
41
61
|
};
|
|
62
|
+
const writeBundle = async (ca) => {
|
|
63
|
+
const file = options.caBundleFile;
|
|
64
|
+
if (!file || ca === bundled)
|
|
65
|
+
return;
|
|
66
|
+
await mkdir(dirname(file), { recursive: true });
|
|
67
|
+
// Written beside it and renamed, so a command never reads half a bundle.
|
|
68
|
+
const next = `${file}.${randomUUID()}`;
|
|
69
|
+
await writeFile(next, `${rootCertificates.join("\n")}\n${ca.trim()}\n`, { mode: 0o644 });
|
|
70
|
+
await rename(next, file);
|
|
71
|
+
bundled = ca;
|
|
72
|
+
};
|
|
42
73
|
/** The volume's id, created on a volume that has none. */
|
|
43
74
|
const volumeId = async () => {
|
|
44
75
|
const file = options.volumeFile;
|
|
@@ -100,7 +131,11 @@ export function podHost(config, logger, options = {}) {
|
|
|
100
131
|
url.username = "nylorun";
|
|
101
132
|
url.password = held.egressToken;
|
|
102
133
|
const proxy = url.toString().replace(/\/$/, "");
|
|
134
|
+
const trust = bundled !== undefined && options.caBundleFile
|
|
135
|
+
? Object.fromEntries(TRUST_VARIABLES.map((name) => [name, options.caBundleFile]))
|
|
136
|
+
: {};
|
|
103
137
|
return {
|
|
138
|
+
...trust,
|
|
104
139
|
HTTPS_PROXY: proxy,
|
|
105
140
|
HTTP_PROXY: proxy,
|
|
106
141
|
https_proxy: proxy,
|
|
@@ -14,6 +14,8 @@ export interface SegmentStart {
|
|
|
14
14
|
readonly sandbox: {
|
|
15
15
|
readonly ownerId: string;
|
|
16
16
|
readonly sandboxId?: string;
|
|
17
|
+
/** Variables every sandbox command gets (R2c): secret names set to the sentinel, plain values. */
|
|
18
|
+
readonly environment?: Readonly<Record<string, string>>;
|
|
17
19
|
};
|
|
18
20
|
/** The sandbox is a pod sandbox (F7.2): its engine runs the segment. Its lifecycle state. */
|
|
19
21
|
readonly pod?: {
|
|
@@ -25,8 +25,10 @@ options = {}) {
|
|
|
25
25
|
const fixtureModel = await usesFixtureModel(t);
|
|
26
26
|
if (!options.harness)
|
|
27
27
|
return { current, fixtureModel, outcomes: [], sandbox: { ownerId: id } };
|
|
28
|
-
const
|
|
29
|
-
const resource =
|
|
28
|
+
const workspace = sandboxWorkspaceOf(current, await sandboxLookup(t, current.id));
|
|
29
|
+
const resource = workspace.sandboxId === undefined ? undefined : await t.sandboxResource(workspace.sandboxId);
|
|
30
|
+
const { environment } = await ctx.vault.sessionEnvironment(t, current.vaultIds ?? []);
|
|
31
|
+
const sandbox = Object.keys(environment).length > 0 ? { ...workspace, environment } : workspace;
|
|
30
32
|
return {
|
|
31
33
|
current,
|
|
32
34
|
fixtureModel,
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* /nylorun/harness/v1/host/join` (`{ sandboxId, podUid, joinToken }`), renews it with `POST
|
|
15
15
|
* .../host/renew` (`Authorization: Bearer <host token>`), and upgrades with the host token as
|
|
16
16
|
* its bearer: that connection hosts its sandbox alone (`HarnessPeer.host`). Both answer
|
|
17
|
-
* `{ hostToken, egressToken, sandboxId, epoch, expiresAt }
|
|
18
|
-
* harness token is never accepted as a host token, nor a host token anywhere but here.
|
|
17
|
+
* `{ hostToken, egressToken, sandboxId, epoch, expiresAt, caCertificate }` (the egress CA, R2c);
|
|
18
|
+
* every refusal is the same 401. The harness token is never accepted as a host token, nor a host token anywhere but here.
|
|
19
19
|
*/
|
|
20
20
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
21
21
|
import { createServer } from "node:http";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ResourceServerConfig } from "../tenant/resource-server.js";
|
|
2
2
|
import type { Logger, TenantModule } from "../tenant/types.js";
|
|
3
3
|
import type { HostConfigFile } from "./config.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type ContainerListen } from "./stack-config.js";
|
|
5
5
|
export interface CreateHostOptions {
|
|
6
6
|
hostRoot: string;
|
|
7
7
|
module: TenantModule;
|
package/dist/host/create-host.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createServer, } from "node:http";
|
|
2
2
|
import { getRequestListener, RequestError } from "@hono/node-server";
|
|
3
3
|
import { createHostApp } from "./app.js";
|
|
4
|
-
import {
|
|
4
|
+
import { isLoopbackHost } from "./stack-config.js";
|
|
5
|
+
import { bindListener as bind, EXIT_NON_LOOPBACK, headerValue, HostListenError, isAllowedRequestHost, pathnameIsLogged, redactRoutePath, rejectedResponse, sendRejected, } from "./http.js";
|
|
5
6
|
export function createHost(options) {
|
|
6
7
|
const { module, config, logger, coreVersion } = options;
|
|
7
8
|
const pid = options.pid ?? process.pid;
|
package/dist/host/gates.js
CHANGED
|
@@ -27,6 +27,7 @@ import { probeDatabase } from "../infra/database.js";
|
|
|
27
27
|
import { bindListener, headerValue, isAllowedRequestHost, sendRejected } from "./http.js";
|
|
28
28
|
import { startEgressGate, storeEgressSandboxes } from "../gates/egress.js";
|
|
29
29
|
import { verifyEgressToken } from "../sandbox/egress-token.js";
|
|
30
|
+
import { leafKeyPair } from "../keys/x509.js";
|
|
30
31
|
/** Above the gate's 600 s provider request timeout and the loop's 630 s client timeout. */
|
|
31
32
|
export const GATES_REQUEST_TIMEOUT_MS = 660_000;
|
|
32
33
|
export async function startGates(options) {
|
|
@@ -172,6 +173,16 @@ export async function startEgress(options) {
|
|
|
172
173
|
return verifyEgressToken(vault.store, vault.tenantId, raw, publicKeys);
|
|
173
174
|
},
|
|
174
175
|
sandboxes: { live: async (sandboxId) => storeEgressSandboxes((await tenant.open()).store).live(sandboxId) },
|
|
176
|
+
credentials: {
|
|
177
|
+
bound: async (sandboxId, host) => (await tenant.open()).environmentSecretBound(sandboxId, host),
|
|
178
|
+
release: async (sandboxId, host) => (await tenant.open()).releaseEnvironmentSecret(sandboxId, host),
|
|
179
|
+
// A fresh key per connection, made here: keys signs its public half and never sees it.
|
|
180
|
+
async leaf(host) {
|
|
181
|
+
const pair = leafKeyPair();
|
|
182
|
+
const cert = await (await tenant.open()).keys().signEgressLeaf(host, pair.publicKey);
|
|
183
|
+
return { key: pair.privateKey, cert };
|
|
184
|
+
},
|
|
185
|
+
},
|
|
175
186
|
...options.gate,
|
|
176
187
|
});
|
|
177
188
|
}
|
package/dist/host/http.d.ts
CHANGED
|
@@ -54,4 +54,3 @@ export declare function adminKeyMatches(presented: string | undefined, adminKey:
|
|
|
54
54
|
export declare function headerValue(incoming: IncomingMessage, name: string): string | undefined;
|
|
55
55
|
/** A JSON request body, capped at `limit` bytes (413); `undefined` when there is none. */
|
|
56
56
|
export declare function readJsonBody(request: Request, limit?: number): Promise<unknown>;
|
|
57
|
-
export declare function isLoopbackHost(host: string): boolean;
|
package/dist/host/http.js
CHANGED
|
@@ -191,9 +191,3 @@ export async function readJsonBody(request, limit = 1024 * 1024) {
|
|
|
191
191
|
throw Object.assign(new Error("Invalid JSON"), { status: 400 });
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
|
-
export function isLoopbackHost(host) {
|
|
195
|
-
const normalized = host.trim().toLowerCase();
|
|
196
|
-
return (normalized === "127.0.0.1" ||
|
|
197
|
-
normalized === "::1" ||
|
|
198
|
-
normalized === "localhost");
|
|
199
|
-
}
|
|
@@ -237,6 +237,8 @@ export declare function parseServices(argv: readonly string[]): ServiceSelection
|
|
|
237
237
|
* IPv6 literals must be bracketed (`[::1]:4000`).
|
|
238
238
|
*/
|
|
239
239
|
export declare function normalizeAllowedHost(name: string, entry: string): string;
|
|
240
|
+
/** A loopback bind address: `127.0.0.1`, `::1` or `localhost`. */
|
|
241
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
240
242
|
/** Parse the stack configuration; throws `StackConfigError` naming the variable. */
|
|
241
243
|
export declare function parseStackConfig(env: EnvSnapshot, argv: readonly string[]): StackConfig;
|
|
242
244
|
/** Endpoint summary for logs: which endpoints are set, never their values. */
|
|
@@ -179,9 +179,10 @@ export function normalizeAllowedHost(name, entry) {
|
|
|
179
179
|
function loopbackForms(port) {
|
|
180
180
|
return [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
|
|
181
181
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
/** A loopback bind address: `127.0.0.1`, `::1` or `localhost`. */
|
|
183
|
+
export function isLoopbackHost(host) {
|
|
184
|
+
const normalized = host.trim().toLowerCase();
|
|
185
|
+
return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
|
|
185
186
|
}
|
|
186
187
|
function parseUrl(env, name, protocols) {
|
|
187
188
|
const raw = read(env, name);
|
|
@@ -198,32 +199,37 @@ function parseUrl(env, name, protocols) {
|
|
|
198
199
|
throw new StackConfigError(`${name} must use ${protocols.map((p) => p.replace(/:$/, "")).join(" or ")}; got ${url.protocol.replace(/:$/, "")}`);
|
|
199
200
|
return raw;
|
|
200
201
|
}
|
|
201
|
-
function
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
const rawAllowed = read(env, "NYLORUN_ALLOWED_HOSTS");
|
|
205
|
-
if (rawHost === undefined && rawPort === undefined && rawAllowed === undefined)
|
|
206
|
-
return undefined;
|
|
207
|
-
const host = rawHost ?? DEFAULT_CONTAINER_LISTEN_HOST;
|
|
202
|
+
function parseListener(env, prefix, defaultPort, hosts) {
|
|
203
|
+
const hostName = `${prefix}LISTEN_HOST`;
|
|
204
|
+
const host = read(env, hostName) ?? DEFAULT_CONTAINER_LISTEN_HOST;
|
|
208
205
|
if (/\s|\//.test(host))
|
|
209
|
-
throw new StackConfigError(
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
206
|
+
throw new StackConfigError(`${hostName} is not an address: ${host}`);
|
|
207
|
+
const portName = `${prefix}LISTEN_PORT`;
|
|
208
|
+
const rawPort = read(env, portName);
|
|
209
|
+
const port = rawPort === undefined ? defaultPort : parsePort(portName, rawPort);
|
|
210
|
+
if (!hosts)
|
|
211
|
+
return { host, port };
|
|
212
|
+
const allowedName = `${prefix}ALLOWED_HOSTS`;
|
|
213
|
+
const explicit = (read(env, allowedName) ?? "")
|
|
214
|
+
.split(",")
|
|
215
|
+
.map((entry) => entry.trim())
|
|
216
|
+
.filter((entry) => entry !== "")
|
|
217
|
+
.map((entry) => normalizeAllowedHost(allowedName, entry));
|
|
218
|
+
if (explicit.length === 0 && !isLoopbackHost(host))
|
|
219
|
+
throw new StackConfigError(`${allowedName} is required when ${hostName} is ${host}: list the Host headers ${hosts.senders}, e.g. ${hosts.example(port)}`);
|
|
222
220
|
// Loopback forms of the listen port serve probes from inside the container
|
|
223
221
|
// (health checks). A browser on the Docker host never sends them: it sends
|
|
224
222
|
// the published port.
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
return { host, port, allowedHosts: [...new Set([...explicit, ...loopbackForms(port)])] };
|
|
224
|
+
}
|
|
225
|
+
/** The API's listener (`NYLORUN_LISTEN_*`, `NYLORUN_ALLOWED_HOSTS`): none when none is set. */
|
|
226
|
+
function parseListen(env) {
|
|
227
|
+
if (["NYLORUN_LISTEN_HOST", "NYLORUN_LISTEN_PORT", "NYLORUN_ALLOWED_HOSTS"].every((name) => read(env, name) === undefined))
|
|
228
|
+
return undefined;
|
|
229
|
+
return parseListener(env, "NYLORUN_", DEFAULT_CONTAINER_LISTEN_PORT, {
|
|
230
|
+
senders: "clients send",
|
|
231
|
+
example: (port) => `runtime:${port},localhost:<published port>`,
|
|
232
|
+
});
|
|
227
233
|
}
|
|
228
234
|
const IDENTITY_KEY = /^publickeyv1_[1-9A-HJ-NP-Za-km-z]{32,64}$/;
|
|
229
235
|
function parseIdentityKeys(env) {
|
|
@@ -398,30 +404,14 @@ function parseSandboxes(env) {
|
|
|
398
404
|
* (required with `NYLORUN_HARNESS=remote`; optional when only sandbox pods connect).
|
|
399
405
|
*/
|
|
400
406
|
function parseHarnessListener(env, tokenRequired = true) {
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const port = rawPort === undefined
|
|
406
|
-
? DEFAULT_HARNESS_LISTEN_PORT
|
|
407
|
-
: parsePort("NYLORUN_HARNESS_LISTEN_PORT", rawPort);
|
|
408
|
-
const rawAllowed = read(env, "NYLORUN_HARNESS_ALLOWED_HOSTS");
|
|
409
|
-
const explicit = rawAllowed === undefined
|
|
410
|
-
? []
|
|
411
|
-
: rawAllowed
|
|
412
|
-
.split(",")
|
|
413
|
-
.map((entry) => entry.trim())
|
|
414
|
-
.filter((entry) => entry !== "")
|
|
415
|
-
.map((entry) => normalizeAllowedHost("NYLORUN_HARNESS_ALLOWED_HOSTS", entry));
|
|
416
|
-
if (explicit.length === 0 && !isLoopbackAddress(host))
|
|
417
|
-
throw new StackConfigError(`NYLORUN_HARNESS_ALLOWED_HOSTS is required when NYLORUN_HARNESS_LISTEN_HOST is ${host}: list the Host headers harnesses send, e.g. runtime:${port}`);
|
|
407
|
+
const listen = parseListener(env, "NYLORUN_HARNESS_", DEFAULT_HARNESS_LISTEN_PORT, {
|
|
408
|
+
senders: "harnesses send",
|
|
409
|
+
example: (port) => `runtime:${port}`,
|
|
410
|
+
});
|
|
418
411
|
const token = tokenRequired || read(env, "NYLORUN_HARNESS_TOKEN") !== undefined
|
|
419
412
|
? parseHarnessToken(env, "with NYLORUN_HARNESS=remote: the credential harnesses present")
|
|
420
413
|
: undefined;
|
|
421
|
-
return {
|
|
422
|
-
listen: { host, port, allowedHosts: [...new Set([...explicit, ...loopbackForms(port)])] },
|
|
423
|
-
...(token ? { token } : {}),
|
|
424
|
-
};
|
|
414
|
+
return { listen, ...(token ? { token } : {}) };
|
|
425
415
|
}
|
|
426
416
|
/** What a harness process must not hold: it runs next to agent code. */
|
|
427
417
|
const HARNESS_REFUSED = ["NYLORUN_DATABASE_URL", "NYLORUN_GATES_TOKEN", "NYLORUN_KEYS_URL"];
|
|
@@ -491,43 +481,22 @@ function parsePodHost(env, url) {
|
|
|
491
481
|
}
|
|
492
482
|
/** `StackConfig.gates` from `NYLORUN_GATES_*`. */
|
|
493
483
|
function parseGates(env) {
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const port = rawPort === undefined
|
|
499
|
-
? DEFAULT_GATES_LISTEN_PORT
|
|
500
|
-
: parsePort("NYLORUN_GATES_LISTEN_PORT", rawPort);
|
|
501
|
-
const rawAllowed = read(env, "NYLORUN_GATES_ALLOWED_HOSTS");
|
|
502
|
-
const explicit = rawAllowed === undefined
|
|
503
|
-
? []
|
|
504
|
-
: rawAllowed
|
|
505
|
-
.split(",")
|
|
506
|
-
.map((entry) => entry.trim())
|
|
507
|
-
.filter((entry) => entry !== "")
|
|
508
|
-
.map((entry) => normalizeAllowedHost("NYLORUN_GATES_ALLOWED_HOSTS", entry));
|
|
509
|
-
if (explicit.length === 0 && !isLoopbackAddress(host))
|
|
510
|
-
throw new StackConfigError(`NYLORUN_GATES_ALLOWED_HOSTS is required when NYLORUN_GATES_LISTEN_HOST is ${host}: list the Host headers the loop sends, e.g. gateway:${port}`);
|
|
484
|
+
const listen = parseListener(env, "NYLORUN_GATES_", DEFAULT_GATES_LISTEN_PORT, {
|
|
485
|
+
senders: "the loop sends",
|
|
486
|
+
example: (port) => `gateway:${port}`,
|
|
487
|
+
});
|
|
511
488
|
const token = read(env, "NYLORUN_GATES_TOKEN");
|
|
512
489
|
if (token === undefined)
|
|
513
490
|
throw new StackConfigError("NYLORUN_GATES_TOKEN is required for --service gates: the token the loop presents (`nylorun start` sets it)");
|
|
514
491
|
if (!GATES_TOKEN.test(token))
|
|
515
492
|
throw new StackConfigError("NYLORUN_GATES_TOKEN must be at least 32 bytes as hex");
|
|
516
|
-
return {
|
|
517
|
-
listen: { host, port, allowedHosts: [...new Set([...explicit, ...loopbackForms(port)])] },
|
|
518
|
-
token,
|
|
519
|
-
};
|
|
493
|
+
return { listen, token };
|
|
520
494
|
}
|
|
521
495
|
/** The prefix of the credential resolver's variables (removed in protocol 10). */
|
|
522
496
|
const REMOVED_RESOLVER_PREFIX = "NYLORUN_RESOLVER_";
|
|
523
497
|
/** `StackConfig.egress` from `NYLORUN_EGRESS_LISTEN_*`. CONNECT has no Host header to check. */
|
|
524
498
|
function parseEgress(env) {
|
|
525
|
-
|
|
526
|
-
if (/\s|\//.test(host))
|
|
527
|
-
throw new StackConfigError(`NYLORUN_EGRESS_LISTEN_HOST is not an address: ${host}`);
|
|
528
|
-
const rawPort = read(env, "NYLORUN_EGRESS_LISTEN_PORT");
|
|
529
|
-
const port = rawPort === undefined ? DEFAULT_EGRESS_LISTEN_PORT : parsePort("NYLORUN_EGRESS_LISTEN_PORT", rawPort);
|
|
530
|
-
return { listen: { host, port } };
|
|
499
|
+
return { listen: parseListener(env, "NYLORUN_EGRESS_", DEFAULT_EGRESS_LISTEN_PORT) };
|
|
531
500
|
}
|
|
532
501
|
/** `StackConfig.modelGate` from `NYLORUN_GATES_URL` and `NYLORUN_GATES_TOKEN`. */
|
|
533
502
|
function parseModelGate(env) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SessionStore } from "../store/types.js";
|
|
2
|
+
/** How long a leaf egress-gate presents is valid: one connection rarely lasts this long. */
|
|
3
|
+
export declare const EGRESS_LEAF_MINUTES = 60;
|
|
4
|
+
export declare class EgressAuthority {
|
|
5
|
+
private readonly store;
|
|
6
|
+
private readonly kek;
|
|
7
|
+
/** The CA with its key, decrypted once per process, as signing keys are. */
|
|
8
|
+
private authority;
|
|
9
|
+
constructor(store: SessionStore, kek: () => Buffer);
|
|
10
|
+
/** The CA certificate (PEM), creating the CA when the Tenant has none. */
|
|
11
|
+
certificate(): Promise<string>;
|
|
12
|
+
/** A leaf for `host` and the caller's P-256 public key (SPKI PEM); the caller keeps its key. */
|
|
13
|
+
signLeaf(host: string, publicKey: string): Promise<string>;
|
|
14
|
+
private load;
|
|
15
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The installation's egress CA (R2c, D50, E12): one long-lived certificate authority whose
|
|
3
|
+
* certificate every pod sandbox trusts, and whose key signs the short-lived leaf egress-gate
|
|
4
|
+
* presents when it terminates TLS for a host an `environment_secret` is bound to. Only keys holds
|
|
5
|
+
* the key: it lives in the Tenant setting `egress.ca`, sealed under the vault KEK like a signing
|
|
6
|
+
* key, beside the certificate in the clear. Created on first use under the signing keys' lock.
|
|
7
|
+
* No rotation yet (R8, with the KeyRoot seam).
|
|
8
|
+
*/
|
|
9
|
+
import { X509Certificate } from "node:crypto";
|
|
10
|
+
import { decryptSecret, encryptSecret } from "../vault/crypto.js";
|
|
11
|
+
import { createCertificateAuthority, issueLeafCertificate } from "./x509.js";
|
|
12
|
+
const SETTING = "egress.ca";
|
|
13
|
+
/** How long a leaf egress-gate presents is valid: one connection rarely lasts this long. */
|
|
14
|
+
export const EGRESS_LEAF_MINUTES = 60;
|
|
15
|
+
function aad(certificate) {
|
|
16
|
+
return Buffer.from(`nylorun.egress-ca:${new X509Certificate(certificate).fingerprint256}`);
|
|
17
|
+
}
|
|
18
|
+
export class EgressAuthority {
|
|
19
|
+
store;
|
|
20
|
+
kek;
|
|
21
|
+
/** The CA with its key, decrypted once per process, as signing keys are. */
|
|
22
|
+
authority;
|
|
23
|
+
constructor(store, kek) {
|
|
24
|
+
this.store = store;
|
|
25
|
+
this.kek = kek;
|
|
26
|
+
}
|
|
27
|
+
/** The CA certificate (PEM), creating the CA when the Tenant has none. */
|
|
28
|
+
async certificate() {
|
|
29
|
+
return (await this.load()).certificate;
|
|
30
|
+
}
|
|
31
|
+
/** A leaf for `host` and the caller's P-256 public key (SPKI PEM); the caller keeps its key. */
|
|
32
|
+
async signLeaf(host, publicKey) {
|
|
33
|
+
return issueLeafCertificate({ authority: await this.load(), host, publicKey, minutes: EGRESS_LEAF_MINUTES });
|
|
34
|
+
}
|
|
35
|
+
async load() {
|
|
36
|
+
if (this.authority)
|
|
37
|
+
return this.authority;
|
|
38
|
+
const kek = this.kek();
|
|
39
|
+
const stored = await this.store.tx(async (t) => (await read(t)) ?? (await create(t, kek)));
|
|
40
|
+
const privateKey = decryptSecret(kek, aad(stored.certificate), Buffer.from(stored.nonce, "base64"), Buffer.from(stored.ciphertext, "base64"), Buffer.from(stored.wrappedDek, "base64"), stored.kekId).toString("utf8");
|
|
41
|
+
this.authority = { certificate: stored.certificate, privateKey };
|
|
42
|
+
return this.authority;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function read(t) {
|
|
46
|
+
const value = await t.getSetting(SETTING);
|
|
47
|
+
return value === undefined ? undefined : JSON.parse(value);
|
|
48
|
+
}
|
|
49
|
+
async function create(t, kek) {
|
|
50
|
+
// Concurrent first uses would both create a CA: lock, then read what the winner made.
|
|
51
|
+
await t.lockSigningKeys();
|
|
52
|
+
const existing = await read(t);
|
|
53
|
+
if (existing)
|
|
54
|
+
return existing;
|
|
55
|
+
const authority = createCertificateAuthority({ commonName: "Nylorun egress CA" });
|
|
56
|
+
const sealed = encryptSecret(kek, aad(authority.certificate), Buffer.from(authority.privateKey, "utf8"));
|
|
57
|
+
const stored = {
|
|
58
|
+
certificate: authority.certificate,
|
|
59
|
+
kekId: sealed.kekId,
|
|
60
|
+
nonce: sealed.nonce.toString("base64"),
|
|
61
|
+
ciphertext: sealed.ciphertext.toString("base64"),
|
|
62
|
+
wrappedDek: sealed.wrappedDek.toString("base64"),
|
|
63
|
+
};
|
|
64
|
+
await t.putSetting(SETTING, JSON.stringify(stored));
|
|
65
|
+
return stored;
|
|
66
|
+
}
|
package/dist/keys/keys.d.ts
CHANGED
|
@@ -32,9 +32,16 @@ export interface Keys {
|
|
|
32
32
|
* (R2b C12, `mcp/preview.ts`); never calls a tool.
|
|
33
33
|
*/
|
|
34
34
|
previewMcp(body: McpPreviewRequest): Promise<McpPreview>;
|
|
35
|
+
/** The egress CA's certificate (PEM), creating the CA when the Tenant has none (R2c, D50). */
|
|
36
|
+
egressCa(): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* A short-lived leaf for `host` and the caller's P-256 public key (SPKI PEM), signed by the
|
|
39
|
+
* egress CA (R2c). The leaf's private key never reaches keys; the CA's never leaves it.
|
|
40
|
+
*/
|
|
41
|
+
signEgressLeaf(host: string, publicKey: string): Promise<string>;
|
|
35
42
|
}
|
|
36
43
|
/** The operations, by the name the HTTP route carries. */
|
|
37
|
-
export declare const KEYS_OPERATIONS: readonly ["createCredential", "rotateCredential", "putHostModel", "selectHostModel", "sign", "rotateSigningKeys", "ensureSigningKeys", "previewMcp"];
|
|
44
|
+
export declare const KEYS_OPERATIONS: readonly ["createCredential", "rotateCredential", "putHostModel", "selectHostModel", "sign", "rotateSigningKeys", "ensureSigningKeys", "previewMcp", "egressCa", "signEgressLeaf"];
|
|
38
45
|
export type KeysOperation = (typeof KEYS_OPERATIONS)[number];
|
|
39
46
|
export interface InProcessKeysOptions {
|
|
40
47
|
readonly store: SessionStore;
|
package/dist/keys/keys.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* service itself, which serves the HTTP routes with it), and `httpKeys` (`client.ts`), the
|
|
13
13
|
* runtime container's client of the `keys` service in the gateway.
|
|
14
14
|
*/
|
|
15
|
+
import { EgressAuthority } from "./egress-ca.js";
|
|
15
16
|
import { SignJWT } from "jose";
|
|
16
17
|
import { previewMcpServer } from "../mcp/preview.js";
|
|
17
18
|
import { signingKeyView } from "../tenant/signing-keys.js";
|
|
@@ -25,9 +26,12 @@ export const KEYS_OPERATIONS = [
|
|
|
25
26
|
"rotateSigningKeys",
|
|
26
27
|
"ensureSigningKeys",
|
|
27
28
|
"previewMcp",
|
|
29
|
+
"egressCa",
|
|
30
|
+
"signEgressLeaf",
|
|
28
31
|
];
|
|
29
32
|
export function inProcessKeys(options) {
|
|
30
33
|
const { store, vault, signingKeys, kek } = options;
|
|
34
|
+
const egress = new EgressAuthority(store, kek);
|
|
31
35
|
return {
|
|
32
36
|
createCredential: (vaultId, body) => vault.createCredential(vaultId, body),
|
|
33
37
|
rotateCredential: (vaultId, id, body) => vault.rotateCredential(vaultId, id, body),
|
|
@@ -52,5 +56,7 @@ export function inProcessKeys(options) {
|
|
|
52
56
|
await store.tx((t) => signingKeys.ensure(t, key));
|
|
53
57
|
},
|
|
54
58
|
previewMcp: (body) => previewMcpServer(body, { vault, policy: options.policy ?? {} }),
|
|
59
|
+
egressCa: () => egress.certificate(),
|
|
60
|
+
signEgressLeaf: (host, publicKey) => egress.signLeaf(host, publicKey),
|
|
55
61
|
};
|
|
56
62
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface CertificateAuthority {
|
|
2
|
+
/** PEM of the CA certificate: the only part a sandbox ever receives. */
|
|
3
|
+
readonly certificate: string;
|
|
4
|
+
/** PKCS#8 PEM of the CA key: sealed in the vault, used only by keys. */
|
|
5
|
+
readonly privateKey: string;
|
|
6
|
+
}
|
|
7
|
+
/** A new P-256 CA, valid from an hour ago for `days` days. */
|
|
8
|
+
export declare function createCertificateAuthority(options: {
|
|
9
|
+
readonly commonName: string;
|
|
10
|
+
readonly days?: number;
|
|
11
|
+
readonly now?: Date;
|
|
12
|
+
}): CertificateAuthority;
|
|
13
|
+
/**
|
|
14
|
+
* A leaf for `host` and the caller's `publicKey` (SPKI PEM), signed by the CA, valid from five
|
|
15
|
+
* minutes ago for `minutes` minutes. The caller keeps the leaf's private key: only the
|
|
16
|
+
* certificate comes back, so no private key leaves whoever holds it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function issueLeafCertificate(options: {
|
|
19
|
+
readonly authority: CertificateAuthority;
|
|
20
|
+
readonly host: string;
|
|
21
|
+
readonly publicKey: string;
|
|
22
|
+
readonly minutes?: number;
|
|
23
|
+
readonly now?: Date;
|
|
24
|
+
}): string;
|
|
25
|
+
/** A P-256 key pair for a leaf, as PEM: SPKI for the public key, PKCS#8 for the private key. */
|
|
26
|
+
export declare function leafKeyPair(): {
|
|
27
|
+
readonly publicKey: string;
|
|
28
|
+
readonly privateKey: string;
|
|
29
|
+
};
|