@nylorun/runtime 0.35.0-beta → 0.36.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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.36.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - 27253ea: A pod starts faster and its commands can install what they need (ADR-92).
8
+
9
+ - **The pod copies a bundled engine.** The Runtime image ships the pod's engine (`--service harness`) bundled into one script, with the one native addon it loads, as `/engine`: 12 files instead of `/app`'s ~30,000. The sandboxes service's `nylo-copy` copies `/engine` when the image has it, and `/app` otherwise, and the workload runs `/nylo/entry.mjs`. On beta's GKE cluster the copy went from 6–29 s to 0.2 s, and the engine's start from 7.2 s to 3.7 s.
10
+ - **A pod's disk is owned once:** `fsGroupChangePolicy: OnRootMismatch`. A mount of a disk holding 91,000 files went from 15–21 s to 3–8 s.
11
+ - **Commands run with bash** where the image has it (`/bin/bash -c`), as the `bash` tool says; `/bin/sh -c` only in an image without bash.
12
+ - **Node uses the proxy.** `NODE_USE_ENV_PROXY=1` joins the proxy variables a pod's commands get, so Node's `fetch`, and corepack's pnpm and yarn, reach the egress gate.
13
+ - **The dev preset allows toolchain downloads:** `nodejs.org`, `releases.astral.sh`, `static.rust-lang.org`, `sh.rustup.rs`, `dl.google.com`, `mise-versions.jdx.dev`, `tuf-repo-cdn.sigstore.dev`, and `release-assets.githubusercontent.com`, where GitHub's release downloads now redirect.
14
+ - **The egress gate checks the server name.** On a 443 tunnel, the gate reads the client's TLS ClientHello and closes the tunnel when its server name (SNI) isn't the CONNECT host, is missing, or the bytes aren't TLS, recording the refusal (`sni_mismatch`, `sni_missing`, `sni_invalid`, `sni_timeout`). A tunnel to an allowed host can no longer ask a shared front end for another one.
15
+
3
16
  ## 0.35.0-beta
4
17
 
5
18
  ### Minor Changes
@@ -8,11 +8,18 @@ export interface LocalBackendOptions {
8
8
  readonly env: Readonly<Record<string, string | undefined>>;
9
9
  /** The engine's `/harness` on the same volume, a disk root of its versions (R2d step 3). */
10
10
  readonly harnessRoot?: string;
11
+ /** The shell commands run under; default `/bin/bash` where the image has it, else `/bin/sh`. */
12
+ readonly shell?: string;
11
13
  }
12
14
  /**
13
- * How the pod runs a command: `/bin/sh -c` in its own process group, with the base environment,
14
- * the request's variables and the proxy of the moment. The sandbox tools and the disk's setup
15
- * (`harness/setup.ts`) both run commands this way.
15
+ * The shell the `bash` tool's commands run under: bash, as the tool says, where the image has it;
16
+ * `/bin/sh` (dash on Debian and Ubuntu) only in an image without it (ADR-92 §10).
17
+ */
18
+ export declare function commandShell(exists?: (path: string) => boolean): string;
19
+ /**
20
+ * How the pod runs a command: `bash -c` (`sh -c` in an image without bash) in its own process
21
+ * group, with the base environment, the request's variables and the proxy of the moment. The
22
+ * sandbox tools and the disk's setup (`harness/setup.ts`) both run commands this way.
16
23
  */
17
24
  export declare function localExec(options: LocalBackendOptions): (request: ExecRequest, signal: AbortSignal) => Promise<ExecResult>;
18
25
  export declare function localBackend(options: LocalBackendOptions): SandboxBackend;
@@ -17,6 +17,22 @@ import { posix } from "node:path";
17
17
  import { SANDBOX_WORKSPACE } from "@nylorun/core/define";
18
18
  import { SandboxFileTooLargeError } from "../../sandbox/types.js";
19
19
  import { hostTuning } from "../../tuning.js";
20
+ /**
21
+ * The shell the `bash` tool's commands run under: bash, as the tool says, where the image has it;
22
+ * `/bin/sh` (dash on Debian and Ubuntu) only in an image without it (ADR-92 §10).
23
+ */
24
+ export function commandShell(exists = executable) {
25
+ return exists("/bin/bash") ? "/bin/bash" : "/bin/sh";
26
+ }
27
+ function executable(path) {
28
+ try {
29
+ accessSync(path, constants.X_OK);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
20
36
  /** This process's environment without the engine's own variables. */
21
37
  function baseEnv(env, workspace) {
22
38
  const out = {};
@@ -53,16 +69,17 @@ function collector() {
53
69
  };
54
70
  }
55
71
  /**
56
- * How the pod runs a command: `/bin/sh -c` in its own process group, with the base environment,
57
- * the request's variables and the proxy of the moment. The sandbox tools and the disk's setup
58
- * (`harness/setup.ts`) both run commands this way.
72
+ * How the pod runs a command: `bash -c` (`sh -c` in an image without bash) in its own process
73
+ * group, with the base environment, the request's variables and the proxy of the moment. The
74
+ * sandbox tools and the disk's setup (`harness/setup.ts`) both run commands this way.
59
75
  */
60
76
  export function localExec(options) {
61
77
  const env = baseEnv(options.env, options.workspace ?? SANDBOX_WORKSPACE);
78
+ const shell = options.shell ?? commandShell();
62
79
  return (request, signal) => new Promise((resolve, reject) => {
63
80
  if (signal.aborted)
64
81
  return reject(signal.reason ?? new Error("Turn cancelled"));
65
- const child = spawn("/bin/sh", ["-c", request.command], {
82
+ const child = spawn(shell, ["-c", request.command], {
66
83
  cwd: request.cwd,
67
84
  env: { ...env, ...(request.env ?? {}), ...(options.proxyEnv?.() ?? {}) },
68
85
  stdio: ["ignore", "pipe", "pipe"],
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The server name (SNI) in a TLS ClientHello, read from the first bytes a client sends through a
3
+ * tunnel (ADR-92 §11). egress-gate compares it with the CONNECT host, so a tunnel opened to an
4
+ * allowed host can't ask a shared front end for another one (domain fronting by SNI).
5
+ *
6
+ * The ClientHello may span several TLS records; they are reassembled up to `MAX_HELLO_BYTES`.
7
+ */
8
+ /** A ClientHello is far smaller; one that isn't complete by here is refused. */
9
+ export declare const MAX_HELLO_BYTES: number;
10
+ export type ClientHelloRead =
11
+ /** More bytes are needed. */
12
+ {
13
+ readonly status: "incomplete";
14
+ }
15
+ /** Not a TLS ClientHello. */
16
+ | {
17
+ readonly status: "invalid";
18
+ readonly reason: string;
19
+ }
20
+ /** A whole ClientHello; `serverName` is lowercase, or undefined when it names none. */
21
+ | {
22
+ readonly status: "complete";
23
+ readonly serverName?: string;
24
+ };
25
+ /** Reads the ClientHello at the start of `data`, the bytes a client has sent so far. */
26
+ export declare function readClientHello(data: Buffer): ClientHelloRead;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The server name (SNI) in a TLS ClientHello, read from the first bytes a client sends through a
3
+ * tunnel (ADR-92 §11). egress-gate compares it with the CONNECT host, so a tunnel opened to an
4
+ * allowed host can't ask a shared front end for another one (domain fronting by SNI).
5
+ *
6
+ * The ClientHello may span several TLS records; they are reassembled up to `MAX_HELLO_BYTES`.
7
+ */
8
+ /** A ClientHello is far smaller; one that isn't complete by here is refused. */
9
+ export const MAX_HELLO_BYTES = 64 * 1024;
10
+ const RECORD_HANDSHAKE = 0x16;
11
+ const HANDSHAKE_CLIENT_HELLO = 0x01;
12
+ const EXTENSION_SERVER_NAME = 0x0000;
13
+ const NAME_TYPE_HOST = 0x00;
14
+ /** Reads the ClientHello at the start of `data`, the bytes a client has sent so far. */
15
+ export function readClientHello(data) {
16
+ // The handshake message, reassembled from consecutive handshake records.
17
+ const chunks = [];
18
+ let have = 0;
19
+ let need;
20
+ let at = 0;
21
+ while (need === undefined || have < need) {
22
+ if (data.length < at + 5)
23
+ return data.length > MAX_HELLO_BYTES ? invalid("too long") : incomplete;
24
+ if (data[at] !== RECORD_HANDSHAKE)
25
+ return invalid("not a TLS handshake");
26
+ if (data[at + 1] !== 0x03)
27
+ return invalid("not a TLS record");
28
+ const length = data.readUInt16BE(at + 3);
29
+ if (length === 0)
30
+ return invalid("an empty record");
31
+ if (data.length < at + 5 + length)
32
+ return at + 5 + length > MAX_HELLO_BYTES ? invalid("too long") : incomplete;
33
+ const fragment = data.subarray(at + 5, at + 5 + length);
34
+ chunks.push(fragment);
35
+ have += fragment.length;
36
+ at += 5 + length;
37
+ if (need === undefined && have >= 4) {
38
+ const head = Buffer.concat(chunks);
39
+ if (head[0] !== HANDSHAKE_CLIENT_HELLO)
40
+ return invalid("not a ClientHello");
41
+ need = 4 + head.readUIntBE(1, 3);
42
+ if (need > MAX_HELLO_BYTES)
43
+ return invalid("too long");
44
+ }
45
+ }
46
+ return serverNameIn(Buffer.concat(chunks).subarray(4, need));
47
+ }
48
+ /** The server name in a ClientHello's body (after its 4-byte handshake header). */
49
+ function serverNameIn(body) {
50
+ let at = 2 + 32; // client_version, random
51
+ if (body.length < at + 1)
52
+ return invalid("truncated");
53
+ at += 1 + body[at]; // session_id
54
+ if (body.length < at + 2)
55
+ return invalid("truncated");
56
+ at += 2 + body.readUInt16BE(at); // cipher_suites
57
+ if (body.length < at + 1)
58
+ return invalid("truncated");
59
+ at += 1 + body[at]; // compression_methods
60
+ if (body.length === at)
61
+ return { status: "complete" }; // no extensions
62
+ if (body.length < at + 2)
63
+ return invalid("truncated");
64
+ const end = at + 2 + body.readUInt16BE(at);
65
+ if (end > body.length)
66
+ return invalid("truncated");
67
+ at += 2;
68
+ while (at + 4 <= end) {
69
+ const type = body.readUInt16BE(at);
70
+ const length = body.readUInt16BE(at + 2);
71
+ const start = at + 4;
72
+ if (start + length > end)
73
+ return invalid("truncated");
74
+ if (type === EXTENSION_SERVER_NAME)
75
+ return hostIn(body.subarray(start, start + length));
76
+ at = start + length;
77
+ }
78
+ return { status: "complete" };
79
+ }
80
+ /** The host name in a server_name extension's data. */
81
+ function hostIn(extension) {
82
+ if (extension.length < 2)
83
+ return invalid("truncated");
84
+ const end = Math.min(extension.length, 2 + extension.readUInt16BE(0));
85
+ let at = 2;
86
+ while (at + 3 <= end) {
87
+ const type = extension[at];
88
+ const length = extension.readUInt16BE(at + 1);
89
+ if (at + 3 + length > end)
90
+ return invalid("truncated");
91
+ if (type === NAME_TYPE_HOST) {
92
+ const name = extension.subarray(at + 3, at + 3 + length).toString("latin1");
93
+ // A host name is letters, digits, hyphens and dots; anything else (a NUL, a space) is refused.
94
+ if (!/^[A-Za-z0-9.-]+$/.test(name))
95
+ return invalid("a server name that isn't a host name");
96
+ return { status: "complete", serverName: name.toLowerCase().replace(/\.$/, "") };
97
+ }
98
+ at += 3 + length;
99
+ }
100
+ return { status: "complete" };
101
+ }
102
+ const incomplete = { status: "incomplete" };
103
+ const invalid = (reason) => ({ status: "invalid", reason });
@@ -56,6 +56,8 @@ export interface StartEgressGateOptions {
56
56
  readonly cacheMs?: number;
57
57
  /** How long the gate waits for the upstream connection. Default `egress.connectTimeoutMs`. */
58
58
  readonly connectTimeoutMs?: number;
59
+ /** How long a 443 tunnel waits for the client's ClientHello. Default 15 s. */
60
+ readonly helloMs?: number;
59
61
  /** Tests replace name resolution. */
60
62
  readonly lookup?: (host: string) => Promise<readonly {
61
63
  address: string;
@@ -19,6 +19,10 @@
19
19
  * (cloud metadata included), multicast or reserved, else 502 or 403;
20
20
  * 6. the gate connects to that checked address (never to the name again) and pipes.
21
21
  *
22
+ * On port 443 the tunnel waits for the client's TLS ClientHello and refuses it, closing the tunnel,
23
+ * when its server name (SNI) isn't the CONNECT host, is missing, or the bytes aren't TLS (ADR-92
24
+ * §11): a tunnel to an allowed host can't ask a shared front end for another one.
25
+ *
22
26
  * A tunnel is opaque bytes, with two exceptions. First (R2c, D50): port 443 of a host that an
23
27
  * `environment_secret` of a live session on the sandbox is bound to. There the gate terminates
24
28
  * TLS and sets the credential's header on every request (`egress-credentials.ts`), still sending
@@ -48,6 +52,7 @@ import { isPrivateAddress } from "../tenant/outbound.js";
48
52
  import { readSandboxConfig } from "../sandbox/tenant-config.js";
49
53
  import { withinCeiling } from "../sandbox/resolve.js";
50
54
  import { bindListener } from "../host/http.js";
55
+ import { MAX_HELLO_BYTES, readClientHello } from "./client-hello.js";
51
56
  import { hostTuning } from "../tuning.js";
52
57
  import { CONNECT_ESTABLISHED, credentialTerminator, } from "./egress-credentials.js";
53
58
  /**
@@ -361,11 +366,63 @@ export async function startEgressGate(options) {
361
366
  record.closed();
362
367
  });
363
368
  client.write(CONNECT_ESTABLISHED);
364
- if (head.length > 0)
369
+ if (port === 443) {
370
+ // The server name the client asks for must be the host it was allowed (ADR-92 §11).
371
+ const hello = await clientHello(client, head, options.helloMs ?? 15_000);
372
+ if (hello.serverName !== host.toLowerCase()) {
373
+ const reason = hello.serverName === undefined ? `sni_${hello.problem ?? "missing"}` : "sni_mismatch";
374
+ logger.warn("egress_refused", { status: 403, reason, sandboxId, host, port, ...(hello.serverName ? { serverName: hello.serverName } : {}) });
375
+ record.refused(403, reason);
376
+ close();
377
+ return;
378
+ }
379
+ upstream.write(hello.bytes);
380
+ }
381
+ else if (head.length > 0)
365
382
  upstream.write(head);
366
383
  upstream.pipe(client);
367
384
  client.pipe(upstream);
368
385
  }
386
+ /**
387
+ * The client's first bytes on a tunnel, up to the end of its ClientHello, and the server name in
388
+ * it. The client is paused afterwards, so the bytes stay in order for the pipe.
389
+ */
390
+ function clientHello(client, head, timeoutMs) {
391
+ return new Promise((resolve) => {
392
+ let bytes = head;
393
+ let done = false;
394
+ const finish = (result) => {
395
+ if (done)
396
+ return;
397
+ done = true;
398
+ clearTimeout(timer);
399
+ client.off("data", onData);
400
+ client.off("close", onClose);
401
+ client.pause();
402
+ resolve({ bytes, ...result });
403
+ };
404
+ const check = () => {
405
+ const read = readClientHello(bytes);
406
+ if (read.status === "complete")
407
+ finish(read.serverName ? { serverName: read.serverName } : { problem: "missing" });
408
+ else if (read.status === "invalid")
409
+ finish({ problem: "invalid" });
410
+ else if (bytes.length > MAX_HELLO_BYTES)
411
+ finish({ problem: "invalid" });
412
+ };
413
+ const onData = (chunk) => {
414
+ bytes = Buffer.concat([bytes, chunk]);
415
+ check();
416
+ };
417
+ const onClose = () => finish({ problem: "missing" });
418
+ const timer = setTimeout(() => finish({ problem: "timeout" }), timeoutMs);
419
+ timer.unref?.();
420
+ client.on("data", onData);
421
+ client.once("close", onClose);
422
+ if (bytes.length > 0)
423
+ check();
424
+ });
425
+ }
369
426
  /** Connects to the first of `addresses` that answers within the connect timeout. */
370
427
  async function dialFirst(addresses, port) {
371
428
  for (const address of addresses) {
@@ -160,6 +160,9 @@ export function podHost(config, logger, options = {}) {
160
160
  http_proxy: proxy,
161
161
  NO_PROXY: "localhost,127.0.0.1",
162
162
  no_proxy: "localhost,127.0.0.1",
163
+ // Node's fetch (and corepack's pnpm and yarn) use the proxy only with this set; the engine
164
+ // drops `*_PROXY` names from the image's own environment, so it comes from here (ADR-92 §10).
165
+ NODE_USE_ENV_PROXY: "1",
163
166
  };
164
167
  },
165
168
  get epoch() {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The pod's engine (`--service harness` in a sandbox pod), on its own: what `main()` does for that
3
+ * service, without importing the rest of the Host. `scripts/bundle-harness.mjs` bundles this file
4
+ * into one script, which the Runtime image ships as `/engine/harness.mjs` and the sandboxes service
5
+ * copies into each pod instead of `/app` (ADR-92 §5: 12 files instead of about 30,000).
6
+ */
7
+ import { baselineEnvironment } from "./environment.js";
8
+ import { parseStackConfig } from "./stack-config.js";
9
+ import { runHarness } from "../harness/main.js";
10
+ import { useHostTuning } from "../tuning.js";
11
+ import { useHostIdentity } from "../vault/host-identity.js";
12
+ import { useWorkloadToken } from "../vault/workload-token.js";
13
+ async function start() {
14
+ const stack = parseStackConfig(process.env, process.argv.slice(2));
15
+ if (!stack.services.has("harness"))
16
+ throw new Error("This entry runs only --service harness");
17
+ if (stack.tuning)
18
+ useHostTuning(stack.tuning.values, stack.tuning.overrides);
19
+ useWorkloadToken(stack.workloadToken ?? {});
20
+ useHostIdentity(stack.modelHostIdentity ?? []);
21
+ await runHarness(stack, baselineEnvironment(process.env));
22
+ }
23
+ void start().catch((error) => {
24
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25
+ process.exit(1);
26
+ });
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Management API",
5
- "version": "0.35.0-beta",
5
+ "version": "0.36.0-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
package/dist/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Runtime API",
5
- "version": "0.35.0-beta",
5
+ "version": "0.36.0-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
@@ -6,7 +6,7 @@ export declare const DEFAULT_SANDBOX_CPUS = 2;
6
6
  export declare const DEFAULT_SANDBOX_MEMORY_MIB = 1024;
7
7
  /** The default of the Tenant's Environment setting `limits.idle` (`environments.config`, D36). */
8
8
  export declare const DEFAULT_SANDBOX_IDLE = "15m";
9
- /** Package registries and code hosts. Concrete names so every backend can express them. */
9
+ /** Package registries, toolchain downloads and code hosts. Concrete names so every backend can express them. */
10
10
  export declare const DEV_PRESET_HOSTS: readonly string[];
11
11
  export declare function resolveNetwork(manifest: SandboxManifest | undefined): ResolvedNetwork;
12
12
  /**
@@ -6,21 +6,29 @@ export const DEFAULT_SANDBOX_CPUS = 2;
6
6
  export const DEFAULT_SANDBOX_MEMORY_MIB = 1024;
7
7
  /** The default of the Tenant's Environment setting `limits.idle` (`environments.config`, D36). */
8
8
  export const DEFAULT_SANDBOX_IDLE = "15m";
9
- /** Package registries and code hosts. Concrete names so every backend can express them. */
9
+ /** Package registries, toolchain downloads and code hosts. Concrete names so every backend can express them. */
10
10
  export const DEV_PRESET_HOSTS = Object.freeze([
11
- // npm and yarn
11
+ // npm and yarn; Node's own downloads, including node-gyp's headers for native modules
12
12
  "registry.npmjs.org",
13
13
  "registry.yarnpkg.com",
14
- // Python
14
+ "nodejs.org",
15
+ // Python; uv's Python builds
15
16
  "pypi.org",
16
17
  "files.pythonhosted.org",
17
- // Rust
18
+ "releases.astral.sh",
19
+ // Rust and rustup
18
20
  "crates.io",
19
21
  "index.crates.io",
20
22
  "static.crates.io",
21
- // Go
23
+ "static.rust-lang.org",
24
+ "sh.rustup.rs",
25
+ // Go, and Go's toolchain downloads
22
26
  "proxy.golang.org",
23
27
  "sum.golang.org",
28
+ "dl.google.com",
29
+ // mise: its version lists, and the signatures it checks on GitHub releases
30
+ "mise-versions.jdx.dev",
31
+ "tuf-repo-cdn.sigstore.dev",
24
32
  // Debian and Ubuntu package mirrors
25
33
  "deb.debian.org",
26
34
  "security.debian.org",
@@ -32,6 +40,8 @@ export const DEV_PRESET_HOSTS = Object.freeze([
32
40
  "api.github.com",
33
41
  "codeload.github.com",
34
42
  "objects.githubusercontent.com",
43
+ // GitHub's release downloads redirect here
44
+ "release-assets.githubusercontent.com",
35
45
  "raw.githubusercontent.com",
36
46
  "gitlab.com",
37
47
  ]);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.35.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.36.0-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/server/scripts/check-package.mjs, which runs in npm run check.
2
2
  // release:prepare also rewrites this when it bumps @nylorun/runtime.
3
- export const RUNTIME_VERSION = "0.35.0-beta";
3
+ export const RUNTIME_VERSION = "0.36.0-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.35.0-beta",
3
+ "version": "0.36.0-beta",
4
4
  "description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -92,6 +92,7 @@
92
92
  "@types/ws": "^8.18.2",
93
93
  "@typescript/native": "npm:typescript@^7.0.2",
94
94
  "drizzle-kit": "^0.31.11",
95
+ "esbuild": "^0.28.2",
95
96
  "typescript": "npm:@typescript/typescript6@^6.0.2",
96
97
  "vitest": "^5.0.4"
97
98
  }