@nylorun/runtime 0.48.1-beta → 0.49.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 +12 -0
- package/dist/gates/credential-provider.d.ts +10 -0
- package/dist/gates/credential-provider.js +36 -9
- package/dist/gates/egress-decide.d.ts +54 -0
- package/dist/gates/egress-decide.js +148 -0
- package/dist/gates/egress.d.ts +7 -0
- package/dist/gates/egress.js +12 -0
- package/dist/harness/main.js +1 -0
- package/dist/harness/pod.d.ts +5 -0
- package/dist/harness/pod.js +23 -6
- package/dist/host/gates.js +1 -0
- package/dist/host/stack-config.d.ts +10 -0
- package/dist/host/stack-config.js +9 -1
- package/dist/management-openapi.json +1 -1
- package/dist/openapi.json +1 -1
- package/dist/sandbox/pods/client.d.ts +11 -0
- package/dist/sandbox/pods/keys.d.ts +20 -0
- package/dist/sandbox/pods/keys.js +27 -0
- package/dist/sandbox/pods/reconcile.js +21 -0
- package/dist/sandbox/pods/spec.d.ts +2 -0
- package/dist/sandbox/pods/spec.js +1 -0
- package/dist/vault/service.d.ts +10 -0
- package/dist/vault/service.js +32 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.49.0-beta
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c4758c0: Pods on Agent Substrate (preview): keys now reach commands through Substrate's own egress ([ADR-142](https://github.com/nylorun/factory/blob/main/decisions/platform/2026-11-13-substrate-egress-adds-keys.md)). A command sends its stand-in, with no proxy, and Substrate's egress replaces the header on each request to a host the Agent has a key for, so `curl`, `gh` and SDKs work against keyed hosts, over HTTP/2 too. A removed key now fails closed with 403 instead of sending the stand-in.
|
|
8
|
+
|
|
9
|
+
- Each host the Agent has an `environment_secret` or slot for, and each host its owner connected through the outside credential source, becomes an `https` rule in the actor's egress policy that names a reference, never a value.
|
|
10
|
+
- The keys provider (`sandboxes provider`, from the sandboxes image) runs beside Substrate's egress. For every keyed request it asks egress-gate's new decide route (`POST /nylorun/v1/egress/decide`, token `NYLORUN_EGRESS_KEYS_TOKEN`), which checks that the actor is the sandbox's current incarnation and returns the vault's header value. For a person's key it returns only who owns it, and the provider fetches the value from the outside source's new `/v1/fetch`.
|
|
11
|
+
- The outside credential source's `/v1/hosts` may name each host's header (`{ host, header }`); a plain host name still means `Authorization`.
|
|
12
|
+
- The engine on Substrate writes a trust bundle with Substrate's egress CA and names it in `SSL_CERT_FILE` and the other trust variables.
|
|
13
|
+
- `nylorun local sandbox enable --backend substrate` deploys the keys provider in `ate-system`, publishes the edge's keys port (8795 by default), and checks that Substrate's egress was installed asking it (`--credential-provider='[{"name":"keys.nylorun.dev","address":"nylorun-keys-provider.ate-system.svc:50051"}]'`). When it wasn't, keys stay off and `enable` says how to turn them on. `sandbox status` shows whether keys are on.
|
|
14
|
+
|
|
3
15
|
## 0.48.1-beta
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -12,6 +12,11 @@ export declare const PROVIDER_HEADERS: {
|
|
|
12
12
|
};
|
|
13
13
|
/** Every header of this prefix a sandbox sends is dropped before the engine sets its own. */
|
|
14
14
|
export declare const PROVIDER_HEADER_PREFIX = "nylorun-provider-";
|
|
15
|
+
/** A host an owner's key is bound to, and the header the key goes in. */
|
|
16
|
+
export interface ProviderHost {
|
|
17
|
+
readonly host: string;
|
|
18
|
+
readonly header: string;
|
|
19
|
+
}
|
|
15
20
|
/** One variable a pod Agent's segments set: its name and the value the sandbox sees. */
|
|
16
21
|
export interface ProviderVariable {
|
|
17
22
|
readonly name: string;
|
|
@@ -38,6 +43,11 @@ export interface CredentialProvider {
|
|
|
38
43
|
* match as `network.allow`'s do (`egressAllows`); none without an owner.
|
|
39
44
|
*/
|
|
40
45
|
hosts(agentId: string, owner: string | undefined): Promise<readonly string[]>;
|
|
46
|
+
/**
|
|
47
|
+
* The same hosts, each with the header its key goes in (contract v2, ADR-142). Optional for a
|
|
48
|
+
* provider an embedder supplies: without it every host's key goes in `Authorization`.
|
|
49
|
+
*/
|
|
50
|
+
keyedHosts?(agentId: string, owner: string | undefined): Promise<readonly ProviderHost[]>;
|
|
41
51
|
/** Where one request to `path` (origin form, `/v2/projects?x=1`) is handed. */
|
|
42
52
|
forwardUrl(path: string): URL;
|
|
43
53
|
/** The headers that go with it, the provider's token among them. */
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* here runs.
|
|
7
7
|
*
|
|
8
8
|
* Three calls, each with `Authorization: Bearer <token>`:
|
|
9
|
-
* - `POST <url>/v1/hosts` `{ agentId, owner }` → `{ hosts: string
|
|
10
|
-
* are bound to, each an exact host name or a `*.<suffix>` pattern
|
|
11
|
-
* TM-E1). A pattern matches as `network.allow`'s do: `run.googleapis.com` and
|
|
9
|
+
* - `POST <url>/v1/hosts` `{ agentId, owner }` → `{ hosts: (string | { host, header?, format? })[] }`:
|
|
10
|
+
* the hosts the owner's keys are bound to, each an exact host name or a `*.<suffix>` pattern
|
|
11
|
+
* (`*.googleapis.com`, v0.26 TM-E1), with the header the key goes in (v2, ADR-142). A pattern matches as `network.allow`'s do: `run.googleapis.com` and
|
|
12
12
|
* `a.b.googleapis.com`, never `googleapis.com` itself. An IP literal or a malformed entry is
|
|
13
13
|
* dropped. egress-gate lets a pod Agent's sandbox reach them (inside the Tenant's egress
|
|
14
14
|
* ceiling), terminates TLS for them on 443 and hands each request on;
|
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
* sent them (its own `Authorization` or inject header included, which the provider replaces),
|
|
24
24
|
* plus `nylorun-provider-host`, `-agent`, `-owner` and, in place of a bearer the sandbox's own
|
|
25
25
|
* `Authorization` would clash with, `nylorun-provider-token`. The provider sends it on to
|
|
26
|
-
* `https://<host>/<path>` and streams the answer back (`egress-credentials.ts`).
|
|
26
|
+
* `https://<host>/<path>` and streams the answer back (`egress-credentials.ts`). Retires with
|
|
27
|
+
* egress-gate (ADR-142);
|
|
28
|
+
* - `POST <url>/v1/fetch` `{ agentId, owner, host }` → `{ value }` (v2, ADR-142): the whole header
|
|
29
|
+
* value of the owner's key for `host`, asked by the keys provider beside Agent Substrate's
|
|
30
|
+
* egress after the Tenant allowed it, never by the Runtime. `404` when there is no such key.
|
|
27
31
|
*
|
|
28
32
|
* Only an Agent with an owner (its owner guard) is asked about; one without gets nothing and no
|
|
29
33
|
* call. An answer is kept per Agent and owner for `credentialProvider.cacheMs` (15 s). A call
|
|
@@ -131,7 +135,8 @@ export function createCredentialProvider(settings, options) {
|
|
|
131
135
|
return {
|
|
132
136
|
url,
|
|
133
137
|
environment: (agentId, owner) => answer("environment", agentId, owner),
|
|
134
|
-
hosts: (agentId, owner) => answer("hosts", agentId, owner),
|
|
138
|
+
hosts: async (agentId, owner) => (await answer("hosts", agentId, owner)).map((entry) => entry.host),
|
|
139
|
+
keyedHosts: (agentId, owner) => answer("hosts", agentId, owner),
|
|
135
140
|
forwardUrl: (path) => new URL(`${url}/v1/forward${path.startsWith("/") ? path : `/${path}`}`),
|
|
136
141
|
forwardHeaders: ({ host, agentId, owner }) => ({
|
|
137
142
|
[PROVIDER_HEADERS.host]: host,
|
|
@@ -141,13 +146,35 @@ export function createCredentialProvider(settings, options) {
|
|
|
141
146
|
}),
|
|
142
147
|
};
|
|
143
148
|
}
|
|
149
|
+
/** An HTTP header name (RFC 9110 token). */
|
|
150
|
+
const HEADER = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/;
|
|
144
151
|
/**
|
|
145
|
-
* The hosts of a `/v1/hosts` answer:
|
|
146
|
-
*
|
|
152
|
+
* The hosts of a `/v1/hosts` answer (contract v2, ADR-142): each entry is a host name or a
|
|
153
|
+
* `*.<suffix>` pattern, or `{ host, header?, format? }` naming the header the key goes in
|
|
154
|
+
* (`Authorization` by default; `format` is the source's own business). Lowercase, without a
|
|
155
|
+
* trailing root dot, once each, the first entry for a host winning; an IP literal or a malformed
|
|
156
|
+
* entry is dropped.
|
|
147
157
|
*/
|
|
148
158
|
function hostsOf(list) {
|
|
149
|
-
const
|
|
150
|
-
|
|
159
|
+
const hosts = new Map();
|
|
160
|
+
for (const item of list) {
|
|
161
|
+
if (hosts.size >= ANSWER_MAX)
|
|
162
|
+
break;
|
|
163
|
+
const entry = typeof item === "string"
|
|
164
|
+
? { host: item, header: undefined }
|
|
165
|
+
: item !== null && typeof item === "object" && typeof item.host === "string"
|
|
166
|
+
? { host: item.host, header: item.header }
|
|
167
|
+
: undefined;
|
|
168
|
+
if (!entry)
|
|
169
|
+
continue;
|
|
170
|
+
const host = entry.host.toLowerCase().replace(/\.$/, "");
|
|
171
|
+
if (!isSandboxHostPattern(host) || hosts.has(host))
|
|
172
|
+
continue;
|
|
173
|
+
if (entry.header !== undefined && (typeof entry.header !== "string" || !HEADER.test(entry.header)))
|
|
174
|
+
continue;
|
|
175
|
+
hosts.set(host, { host, header: entry.header ?? "Authorization" });
|
|
176
|
+
}
|
|
177
|
+
return [...hosts.values()];
|
|
151
178
|
}
|
|
152
179
|
/**
|
|
153
180
|
* The variables of a `/v1/environment` answer, each with the value the sandbox sees: a bare name is
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { Logger } from "../tenant/types.js";
|
|
3
|
+
import type { CredentialProvider } from "./credential-provider.js";
|
|
4
|
+
import type { EgressCredentialRelease } from "./egress-credentials.js";
|
|
5
|
+
/** The route's path. */
|
|
6
|
+
export declare const EGRESS_DECIDE_PATH = "/nylorun/v1/egress/decide";
|
|
7
|
+
/** What the route reads of a sandbox: its incarnation, owner and the Tenant's egress ceiling. */
|
|
8
|
+
export interface DecideSandboxes {
|
|
9
|
+
live(sandboxId: string): Promise<{
|
|
10
|
+
/** The current incarnation's name: the actor's name on Agent Substrate. */
|
|
11
|
+
readonly name?: string;
|
|
12
|
+
readonly owner?: string;
|
|
13
|
+
readonly ceiling?: readonly string[];
|
|
14
|
+
} | undefined>;
|
|
15
|
+
}
|
|
16
|
+
export interface EgressDecideOptions {
|
|
17
|
+
/** NYLORUN_EGRESS_KEYS_TOKEN; none: the route is off. */
|
|
18
|
+
readonly token?: string;
|
|
19
|
+
readonly sandboxes: DecideSandboxes;
|
|
20
|
+
/** The vault's release of an `environment_secret` bound to a host (as egress-gate's). */
|
|
21
|
+
release(sandboxId: string, host: string): Promise<EgressCredentialRelease>;
|
|
22
|
+
/** The outside credential source, for a person's keys. */
|
|
23
|
+
readonly provider?: CredentialProvider;
|
|
24
|
+
readonly logger: Logger;
|
|
25
|
+
}
|
|
26
|
+
export type Decision = {
|
|
27
|
+
readonly status: 200;
|
|
28
|
+
readonly body: {
|
|
29
|
+
readonly value: string;
|
|
30
|
+
} | {
|
|
31
|
+
readonly fetch: {
|
|
32
|
+
readonly agentId: string;
|
|
33
|
+
readonly owner: string;
|
|
34
|
+
readonly host: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
readonly outcome: string;
|
|
38
|
+
readonly credentialId?: string;
|
|
39
|
+
} | {
|
|
40
|
+
readonly status: 400 | 403 | 404 | 503;
|
|
41
|
+
readonly code: string;
|
|
42
|
+
readonly outcome: string;
|
|
43
|
+
};
|
|
44
|
+
/** The decision for one keyed request (steps 1–4 above). */
|
|
45
|
+
export declare function decide(options: Omit<EgressDecideOptions, "token" | "logger">, input: {
|
|
46
|
+
readonly actor: string;
|
|
47
|
+
readonly sandboxId: string;
|
|
48
|
+
readonly host: string;
|
|
49
|
+
}): Promise<Decision>;
|
|
50
|
+
/**
|
|
51
|
+
* The route's handler: `true` when it answered `req`, `false` for any other request, which the
|
|
52
|
+
* caller answers as before.
|
|
53
|
+
*/
|
|
54
|
+
export declare function egressDecideHandler(options: EgressDecideOptions): (req: IncomingMessage, res: ServerResponse) => boolean;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys provider's decide route (ADR-142): on Agent Substrate, Substrate's egress adds a
|
|
3
|
+
* sandbox's keys, and the keys provider beside it asks the Tenant here, for every keyed request,
|
|
4
|
+
* what to add. It is served on egress-gate's listener (the gateway's `--service egress`, 4200),
|
|
5
|
+
* which the sandboxes service's edge forwards its keys port to; no actor's egress policy allows
|
|
6
|
+
* that port.
|
|
7
|
+
*
|
|
8
|
+
* `POST /nylorun/v1/egress/decide` with `Authorization: Bearer <NYLORUN_EGRESS_KEYS_TOKEN>` and
|
|
9
|
+
* `{ actor, sandboxId, host }`, where `actor` is the actor's name as Substrate's egress verified it
|
|
10
|
+
* and `sandboxId` and `host` come from the reference in the actor's rule
|
|
11
|
+
* (`ate-secret://keys.nylorun.dev/<sandboxId>/<host>`). In order:
|
|
12
|
+
*
|
|
13
|
+
* 1. the sandbox is a live pod whose current incarnation is `actor` (its `k8sName`), else 403:
|
|
14
|
+
* one actor can never use another sandbox's reference;
|
|
15
|
+
* 2. an `environment_secret` of its Environment bound to `host` wins, as on egress-gate: the vault
|
|
16
|
+
* releases it (one read, audited, unsealed by keys) and `200 { value }` carries the whole header
|
|
17
|
+
* value; a refusal is 403;
|
|
18
|
+
* 3. a host the outside credential source lists for the Agent's owner, inside the Tenant's egress
|
|
19
|
+
* ceiling, is allowed: `200 { fetch: { agentId, owner, host } }`, and the keys provider asks the
|
|
20
|
+
* source for the value. The Runtime never sees a person's key (ADR-82 §3, D49);
|
|
21
|
+
* 4. else `404 not_found`.
|
|
22
|
+
*
|
|
23
|
+
* Without the token setting the route is off (404). Every decision is logged, never a value.
|
|
24
|
+
*/
|
|
25
|
+
import { timingSafeEqual, createHash } from "node:crypto";
|
|
26
|
+
import { isSandboxHostPattern } from "@nylorun/core/define";
|
|
27
|
+
import { withinCeiling } from "../sandbox/resolve.js";
|
|
28
|
+
import { egressAllows } from "./egress.js";
|
|
29
|
+
/** The route's path. */
|
|
30
|
+
export const EGRESS_DECIDE_PATH = "/nylorun/v1/egress/decide";
|
|
31
|
+
/** The largest body the route reads. */
|
|
32
|
+
const MAX_BODY = 4096;
|
|
33
|
+
function digest(value) {
|
|
34
|
+
return createHash("sha256").update(value).digest();
|
|
35
|
+
}
|
|
36
|
+
/** The decision for one keyed request (steps 1–4 above). */
|
|
37
|
+
export async function decide(options, input) {
|
|
38
|
+
const sandbox = await options.sandboxes.live(input.sandboxId);
|
|
39
|
+
if (!sandbox || sandbox.name === undefined || sandbox.name !== input.actor)
|
|
40
|
+
return { status: 403, code: "permission_denied", outcome: "not_this_sandbox" };
|
|
41
|
+
const released = await options.release(input.sandboxId, input.host);
|
|
42
|
+
if (released.status === "released")
|
|
43
|
+
return { status: 200, body: { value: released.value }, outcome: "vault", credentialId: released.credentialId };
|
|
44
|
+
if (released.status === "refused")
|
|
45
|
+
return { status: 403, code: "permission_denied", outcome: `vault_${released.reason}` };
|
|
46
|
+
const { provider } = options;
|
|
47
|
+
if (provider && sandbox.owner !== undefined) {
|
|
48
|
+
const hosts = await provider.hosts(input.sandboxId, sandbox.owner);
|
|
49
|
+
const listed = hosts.includes(input.host) || egressAllows(input.host, hosts);
|
|
50
|
+
if (listed && sandbox.ceiling && !withinCeiling(input.host, sandbox.ceiling))
|
|
51
|
+
return { status: 403, code: "permission_denied", outcome: "outside_ceiling" };
|
|
52
|
+
if (listed)
|
|
53
|
+
return {
|
|
54
|
+
status: 200,
|
|
55
|
+
body: { fetch: { agentId: input.sandboxId, owner: sandbox.owner, host: input.host } },
|
|
56
|
+
outcome: "outside",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { status: 404, code: "not_found", outcome: "no_key" };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The route's handler: `true` when it answered `req`, `false` for any other request, which the
|
|
63
|
+
* caller answers as before.
|
|
64
|
+
*/
|
|
65
|
+
export function egressDecideHandler(options) {
|
|
66
|
+
const want = options.token ? digest(options.token) : undefined;
|
|
67
|
+
const send = (res, status, body) => {
|
|
68
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
69
|
+
res.end(JSON.stringify(body));
|
|
70
|
+
};
|
|
71
|
+
return (req, res) => {
|
|
72
|
+
const path = (req.url ?? "").split("?")[0];
|
|
73
|
+
if (path !== EGRESS_DECIDE_PATH)
|
|
74
|
+
return false;
|
|
75
|
+
if (!want) {
|
|
76
|
+
send(res, 404, { error: { code: "not_found", message: "keys at Substrate's egress are off" } });
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (req.method !== "POST") {
|
|
80
|
+
send(res, 405, { error: { code: "method_not_allowed", message: "POST only" } });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
const presented = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? "")?.[1];
|
|
84
|
+
if (!presented || !timingSafeEqual(digest(presented), want)) {
|
|
85
|
+
options.logger.warn("egress_decide_refused", { reason: "token" });
|
|
86
|
+
send(res, 401, { error: { code: "unauthorized", message: "missing or wrong token" } });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
const chunks = [];
|
|
90
|
+
let size = 0;
|
|
91
|
+
req.on("data", (chunk) => {
|
|
92
|
+
size += chunk.length;
|
|
93
|
+
if (size <= MAX_BODY)
|
|
94
|
+
chunks.push(chunk);
|
|
95
|
+
});
|
|
96
|
+
req.on("end", () => {
|
|
97
|
+
void (async () => {
|
|
98
|
+
let input;
|
|
99
|
+
try {
|
|
100
|
+
const body = size <= MAX_BODY ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined;
|
|
101
|
+
if (body &&
|
|
102
|
+
typeof body.actor === "string" &&
|
|
103
|
+
typeof body.sandboxId === "string" &&
|
|
104
|
+
typeof body.host === "string" &&
|
|
105
|
+
body.actor !== "" &&
|
|
106
|
+
body.sandboxId !== "" &&
|
|
107
|
+
isSandboxHostPattern(body.host))
|
|
108
|
+
input = { actor: body.actor, sandboxId: body.sandboxId, host: body.host.toLowerCase() };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
input = undefined;
|
|
112
|
+
}
|
|
113
|
+
if (!input) {
|
|
114
|
+
send(res, 400, { error: { code: "invalid_request", message: "the body is {actor, sandboxId, host}" } });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
let decision;
|
|
118
|
+
try {
|
|
119
|
+
decision = await decide(options, input);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
options.logger.warn("egress_decide_failed", {
|
|
123
|
+
sandboxId: input.sandboxId,
|
|
124
|
+
host: input.host,
|
|
125
|
+
error: error instanceof Error ? error.message : String(error),
|
|
126
|
+
});
|
|
127
|
+
send(res, 503, { error: { code: "unavailable", message: "the decision failed; retry" } });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const fields = {
|
|
131
|
+
sandboxId: input.sandboxId,
|
|
132
|
+
host: input.host,
|
|
133
|
+
outcome: decision.outcome,
|
|
134
|
+
...("credentialId" in decision && decision.credentialId ? { credentialId: decision.credentialId } : {}),
|
|
135
|
+
};
|
|
136
|
+
if (decision.status === 200) {
|
|
137
|
+
options.logger.info("egress_decided", fields);
|
|
138
|
+
send(res, 200, decision.body);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
options.logger.warn("egress_decide_refused", fields);
|
|
142
|
+
send(res, decision.status, { error: { code: decision.code, message: decision.outcome } });
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
});
|
|
146
|
+
return true;
|
|
147
|
+
};
|
|
148
|
+
}
|
package/dist/gates/egress.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface EgressSandboxes {
|
|
|
17
17
|
live(sandboxId: string): Promise<{
|
|
18
18
|
readonly epoch: number;
|
|
19
19
|
readonly podUid?: string;
|
|
20
|
+
/** The incarnation's name (`k8sName`): the actor's name on Agent Substrate. */
|
|
21
|
+
readonly name?: string;
|
|
20
22
|
readonly allow: readonly string[];
|
|
21
23
|
readonly owner?: string;
|
|
22
24
|
readonly ceiling?: readonly string[];
|
|
@@ -76,6 +78,11 @@ export interface StartEgressGateOptions {
|
|
|
76
78
|
* Agent's owner pass on 443 and are handed to it. Needs `credentials` (the leaf). None: off.
|
|
77
79
|
*/
|
|
78
80
|
readonly provider?: CredentialProvider;
|
|
81
|
+
/**
|
|
82
|
+
* NYLORUN_EGRESS_KEYS_TOKEN: the keys provider's token for the decide route (ADR-142,
|
|
83
|
+
* `egress-decide.ts`). None: the route is off.
|
|
84
|
+
*/
|
|
85
|
+
readonly keysToken?: string;
|
|
79
86
|
}
|
|
80
87
|
export interface EgressGate {
|
|
81
88
|
/** Where the gate listens, e.g. `http://0.0.0.0:4200`. */
|
package/dist/gates/egress.js
CHANGED
|
@@ -51,6 +51,7 @@ import { isSandboxHostPattern } from "@nylorun/core/define";
|
|
|
51
51
|
import { isPrivateAddress } from "../tenant/outbound.js";
|
|
52
52
|
import { readSandboxConfig } from "../sandbox/tenant-config.js";
|
|
53
53
|
import { withinCeiling } from "../sandbox/resolve.js";
|
|
54
|
+
import { egressDecideHandler } from "./egress-decide.js";
|
|
54
55
|
import { bindListener } from "../host/http.js";
|
|
55
56
|
import { MAX_HELLO_BYTES, readClientHello } from "./client-hello.js";
|
|
56
57
|
import { hostTuning } from "../tuning.js";
|
|
@@ -448,7 +449,17 @@ export async function startEgressGate(options) {
|
|
|
448
449
|
}
|
|
449
450
|
return undefined;
|
|
450
451
|
}
|
|
452
|
+
// Agent Substrate's keys provider asks here what to add to a keyed request (ADR-142).
|
|
453
|
+
const decideRoute = egressDecideHandler({
|
|
454
|
+
...(options.keysToken ? { token: options.keysToken } : {}),
|
|
455
|
+
sandboxes: { live: (sandboxId) => sandboxes.live(sandboxId) },
|
|
456
|
+
release: (sandboxId, host) => options.credentials ? options.credentials.release(sandboxId, host) : Promise.resolve({ status: "none" }),
|
|
457
|
+
...(options.provider ? { provider: options.provider } : {}),
|
|
458
|
+
logger,
|
|
459
|
+
});
|
|
451
460
|
const server = createServer((req, res) => {
|
|
461
|
+
if (decideRoute(req, res))
|
|
462
|
+
return;
|
|
452
463
|
logger.warn("egress_refused", { status: 405, reason: "not_connect", method: req.method });
|
|
453
464
|
res.writeHead(405, { allow: "CONNECT", "content-type": "text/plain; charset=utf-8", connection: "close" });
|
|
454
465
|
res.end("egress-gate: only CONNECT is served; set HTTPS_PROXY and HTTP_PROXY to this address\n");
|
|
@@ -511,6 +522,7 @@ export function storeEgressSandboxes(store) {
|
|
|
511
522
|
return {
|
|
512
523
|
epoch: pod.hostEpoch,
|
|
513
524
|
...(pod.podUid ? { podUid: pod.podUid } : {}),
|
|
525
|
+
name: pod.k8sName,
|
|
514
526
|
// The applied allowlist: a recipe change writes it at once (Q8).
|
|
515
527
|
allow: sandbox.applied.network?.allowedHosts ?? [],
|
|
516
528
|
...(owner === undefined ? {} : { owner }),
|
package/dist/harness/main.js
CHANGED
|
@@ -55,6 +55,7 @@ export async function runHarness(stack, baseline) {
|
|
|
55
55
|
pod = podHost(config.pod, logger, {
|
|
56
56
|
volumeFile: join(config.root, "volume-id"),
|
|
57
57
|
caBundleFile: join(config.root, "egress-ca-bundle.pem"),
|
|
58
|
+
...(config.pod.egressCaFile ? { extraCaFile: config.pod.egressCaFile } : {}),
|
|
58
59
|
});
|
|
59
60
|
// Join, then make the disk ready (R2d step 2) before taking any turn.
|
|
60
61
|
await pod.token();
|
package/dist/harness/pod.d.ts
CHANGED
|
@@ -62,6 +62,11 @@ export declare function podHost(config: PodHostConfig, logger: {
|
|
|
62
62
|
volumeFile?: string;
|
|
63
63
|
/** Where the engine writes the trust bundle: the public roots and the egress CA (R2c). */
|
|
64
64
|
caBundleFile?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Agent Substrate's egress CA (`NYLORUN_SANDBOX_EGRESS_CA_FILE`, ADR-142): Substrate's egress
|
|
67
|
+
* ends TLS for the hosts whose keys it adds, so commands trust it with no proxy in between.
|
|
68
|
+
*/
|
|
69
|
+
extraCaFile?: string;
|
|
65
70
|
/** Whether this engine runs from the workload image (default: from where its code is). */
|
|
66
71
|
imageEngine?: boolean;
|
|
67
72
|
}): PodHost;
|
package/dist/harness/pod.js
CHANGED
|
@@ -57,8 +57,8 @@ export function podHost(config, logger, options = {}) {
|
|
|
57
57
|
if (!response.ok)
|
|
58
58
|
throw new Error(`host/${path} answered ${response.status}`);
|
|
59
59
|
const body = JSON.parse(text);
|
|
60
|
-
if (typeof body.caCertificate === "string")
|
|
61
|
-
await writeBundle(body.caCertificate);
|
|
60
|
+
if (typeof body.caCertificate === "string" || options.extraCaFile)
|
|
61
|
+
await writeBundle(body.caCertificate ?? "");
|
|
62
62
|
if (path === "join") {
|
|
63
63
|
setup = body.setup;
|
|
64
64
|
seeded = body.seeded === true;
|
|
@@ -74,14 +74,24 @@ export function podHost(config, logger, options = {}) {
|
|
|
74
74
|
};
|
|
75
75
|
const writeBundle = async (ca) => {
|
|
76
76
|
const file = options.caBundleFile;
|
|
77
|
-
|
|
77
|
+
let extra = "";
|
|
78
|
+
if (options.extraCaFile) {
|
|
79
|
+
try {
|
|
80
|
+
extra = (await readFile(options.extraCaFile, "utf8")).trim();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Not mounted (yet): the bundle holds the roots and the join's CA.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const content = [ca.trim(), extra].filter((part) => part !== "").join("\n");
|
|
87
|
+
if (!file || content === bundled)
|
|
78
88
|
return;
|
|
79
89
|
await mkdir(dirname(file), { recursive: true });
|
|
80
90
|
// Written beside it and renamed, so a command never reads half a bundle.
|
|
81
91
|
const next = `${file}.${randomUUID()}`;
|
|
82
|
-
await writeFile(next, `${rootCertificates.join("\n")}\n${
|
|
92
|
+
await writeFile(next, `${rootCertificates.join("\n")}\n${content}\n`, { mode: 0o644 });
|
|
83
93
|
await rename(next, file);
|
|
84
|
-
bundled =
|
|
94
|
+
bundled = content;
|
|
85
95
|
};
|
|
86
96
|
/** The volume's id, created on a volume that has none. */
|
|
87
97
|
const volumeId = async () => {
|
|
@@ -149,8 +159,15 @@ export function podHost(config, logger, options = {}) {
|
|
|
149
159
|
return current.hostToken;
|
|
150
160
|
},
|
|
151
161
|
proxyEnv() {
|
|
152
|
-
if (!
|
|
162
|
+
if (!held)
|
|
153
163
|
return {};
|
|
164
|
+
const trustOnly = bundled !== undefined && options.caBundleFile
|
|
165
|
+
? Object.fromEntries(TRUST_VARIABLES.map((name) => [name, options.caBundleFile]))
|
|
166
|
+
: {};
|
|
167
|
+
// On Agent Substrate there is no proxy: Substrate's egress adds keys on its own (ADR-142),
|
|
168
|
+
// and commands only need to trust its CA.
|
|
169
|
+
if (!config.egressProxy)
|
|
170
|
+
return options.extraCaFile ? trustOnly : {};
|
|
154
171
|
const url = new URL(config.egressProxy);
|
|
155
172
|
url.username = "nylorun";
|
|
156
173
|
url.password = held.egressToken;
|
package/dist/host/gates.js
CHANGED
|
@@ -199,6 +199,7 @@ export async function startEgress(options) {
|
|
|
199
199
|
...(options.credentialProvider
|
|
200
200
|
? { provider: createCredentialProvider(options.credentialProvider, { logger: options.logger }) }
|
|
201
201
|
: {}),
|
|
202
|
+
...(options.egress.keysToken ? { keysToken: options.egress.keysToken } : {}),
|
|
202
203
|
...options.gate,
|
|
203
204
|
});
|
|
204
205
|
}
|
|
@@ -118,6 +118,11 @@ export interface EgressConfig {
|
|
|
118
118
|
host: string;
|
|
119
119
|
port: number;
|
|
120
120
|
};
|
|
121
|
+
/**
|
|
122
|
+
* NYLORUN_EGRESS_KEYS_TOKEN: the token Agent Substrate's keys provider presents to the decide
|
|
123
|
+
* route on this listener (ADR-142); none: the route is off. 32 bytes or more as hex.
|
|
124
|
+
*/
|
|
125
|
+
keysToken?: string;
|
|
121
126
|
}
|
|
122
127
|
/** Where the loop reaches the gates service (`NYLORUN_GATES_URL`, `NYLORUN_GATES_TOKEN`). */
|
|
123
128
|
export interface ModelGateEndpoint {
|
|
@@ -159,6 +164,11 @@ export interface PodHostConfig {
|
|
|
159
164
|
httpUrl: string;
|
|
160
165
|
/** `NYLORUN_EGRESS_PROXY`: egress-gate's address, or undefined while it is not published. */
|
|
161
166
|
egressProxy?: string;
|
|
167
|
+
/**
|
|
168
|
+
* `NYLORUN_SANDBOX_EGRESS_CA_FILE`: Agent Substrate's egress CA (ADR-142), which commands trust
|
|
169
|
+
* for the hosts whose keys Substrate's egress adds.
|
|
170
|
+
*/
|
|
171
|
+
egressCaFile?: string;
|
|
162
172
|
/** Addresses the pod's NetworkPolicy must block, probed before the engine starts. */
|
|
163
173
|
blocked: BlockedAddress[];
|
|
164
174
|
}
|
|
@@ -637,6 +637,7 @@ function parsePodHost(env, url) {
|
|
|
637
637
|
const secure = base.protocol === "https:" || base.protocol === "wss:";
|
|
638
638
|
const origin = `${secure ? "https" : "http"}://${base.host}`;
|
|
639
639
|
const egress = read(env, "NYLORUN_EGRESS_PROXY");
|
|
640
|
+
const egressCa = read(env, "NYLORUN_SANDBOX_EGRESS_CA_FILE");
|
|
640
641
|
return {
|
|
641
642
|
wsUrl: `${secure ? "wss" : "ws"}://${base.host}/nylorun/harness/v1`,
|
|
642
643
|
config: {
|
|
@@ -645,6 +646,7 @@ function parsePodHost(env, url) {
|
|
|
645
646
|
joinFile: need("NYLORUN_SANDBOX_JOIN_FILE"),
|
|
646
647
|
httpUrl: origin,
|
|
647
648
|
...(egress ? { egressProxy: egress.replace(/\/+$/, "") } : {}),
|
|
649
|
+
...(egressCa ? { egressCaFile: egressCa } : {}),
|
|
648
650
|
blocked: blockedAddresses(env),
|
|
649
651
|
},
|
|
650
652
|
};
|
|
@@ -666,7 +668,13 @@ function parseGates(env) {
|
|
|
666
668
|
const REMOVED_RESOLVER_PREFIX = "NYLORUN_RESOLVER_";
|
|
667
669
|
/** `StackConfig.egress` from `NYLORUN_EGRESS_LISTEN_*`. CONNECT has no Host header to check. */
|
|
668
670
|
function parseEgress(env) {
|
|
669
|
-
|
|
671
|
+
const keysToken = read(env, "NYLORUN_EGRESS_KEYS_TOKEN");
|
|
672
|
+
if (keysToken !== undefined && !/^[0-9a-f]{64,}$/i.test(keysToken))
|
|
673
|
+
throw new StackConfigError("NYLORUN_EGRESS_KEYS_TOKEN must be 32 bytes or more as hex (nylorun local sandbox enable makes it)");
|
|
674
|
+
return {
|
|
675
|
+
listen: parseListener(env, "NYLORUN_EGRESS_", DEFAULT_EGRESS_LISTEN_PORT),
|
|
676
|
+
...(keysToken === undefined ? {} : { keysToken }),
|
|
677
|
+
};
|
|
670
678
|
}
|
|
671
679
|
/** `StackConfig.modelGate` from `NYLORUN_GATES_URL` and `NYLORUN_GATES_TOKEN`. */
|
|
672
680
|
function parseModelGate(env) {
|
package/dist/openapi.json
CHANGED
|
@@ -33,6 +33,17 @@ export interface PodSpec {
|
|
|
33
33
|
readonly hosts: readonly string[];
|
|
34
34
|
readonly suffixes: readonly string[];
|
|
35
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* The hosts the sandbox has a key for, each with its header (ADR-142, `keys.ts`). agent-sandbox
|
|
38
|
+
* ignores them; on Agent Substrate each becomes an https rule the keys provider fills.
|
|
39
|
+
*/
|
|
40
|
+
readonly keys?: {
|
|
41
|
+
readonly sandbox: string;
|
|
42
|
+
readonly hosts: readonly {
|
|
43
|
+
readonly host: string;
|
|
44
|
+
readonly header: string;
|
|
45
|
+
}[];
|
|
46
|
+
};
|
|
36
47
|
}
|
|
37
48
|
/** A Sandbox as the service's informers see it (`driver.Status` in Go). */
|
|
38
49
|
export interface PodStatus {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hosts a pod sandbox has a key for (ADR-142), as the sandboxes service renders them on Agent
|
|
3
|
+
* Substrate: an https rule per host whose header Substrate's egress asks the keys provider for.
|
|
4
|
+
* agent-sandbox ignores them (egress-gate adds keys there).
|
|
5
|
+
*
|
|
6
|
+
* The hosts of the Environment's `environment_secret`s and slots (read from the clear binding,
|
|
7
|
+
* no value), then the hosts the outside credential source lists for the Agent's owner, inside the
|
|
8
|
+
* Tenant's egress ceiling; the vault's header wins for a host both name, as on egress-gate. A
|
|
9
|
+
* source that fails names none (it logs once), so its hosts are passed through until the next
|
|
10
|
+
* apply. Values are never read here: the keys provider asks for them per request.
|
|
11
|
+
*/
|
|
12
|
+
import type { TenantContext } from "../../tenant/context.js";
|
|
13
|
+
import type { PodSpec } from "./client.js";
|
|
14
|
+
/** The most keyed hosts one sandbox's policy holds (the sandboxes service's bound). */
|
|
15
|
+
export declare const MAX_KEY_HOSTS = 200;
|
|
16
|
+
export declare function podKeysOf(ctx: Pick<TenantContext, "store" | "credentialProvider">, input: {
|
|
17
|
+
readonly sandboxId: string;
|
|
18
|
+
readonly owner?: string;
|
|
19
|
+
readonly ceiling?: readonly string[];
|
|
20
|
+
}): Promise<NonNullable<PodSpec["keys"]>>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { environmentSecretHosts } from "../../vault/service.js";
|
|
2
|
+
import { withinCeiling } from "../resolve.js";
|
|
3
|
+
/** The most keyed hosts one sandbox's policy holds (the sandboxes service's bound). */
|
|
4
|
+
export const MAX_KEY_HOSTS = 200;
|
|
5
|
+
export async function podKeysOf(ctx, input) {
|
|
6
|
+
const hosts = new Map();
|
|
7
|
+
for (const entry of await ctx.store.tx((t) => environmentSecretHosts(t, input.sandboxId)))
|
|
8
|
+
hosts.set(entry.host, entry.header);
|
|
9
|
+
if (ctx.credentialProvider && input.owner !== undefined) {
|
|
10
|
+
const provider = ctx.credentialProvider;
|
|
11
|
+
const listed = provider.keyedHosts
|
|
12
|
+
? await provider.keyedHosts(input.sandboxId, input.owner)
|
|
13
|
+
: (await provider.hosts(input.sandboxId, input.owner)).map((host) => ({ host, header: "Authorization" }));
|
|
14
|
+
for (const entry of listed) {
|
|
15
|
+
if (hosts.has(entry.host))
|
|
16
|
+
continue;
|
|
17
|
+
if (input.ceiling && !withinCeiling(entry.host, input.ceiling))
|
|
18
|
+
continue;
|
|
19
|
+
hosts.set(entry.host, entry.header);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const list = [...hosts.entries()]
|
|
23
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
24
|
+
.slice(0, MAX_KEY_HOSTS)
|
|
25
|
+
.map(([host, header]) => ({ host, header }));
|
|
26
|
+
return { sandbox: input.sandboxId, hosts: list };
|
|
27
|
+
}
|
|
@@ -22,6 +22,7 @@ import { sha256Hex } from "../join.js";
|
|
|
22
22
|
import { recoverLostVolume } from "../../artifacts/disk.js";
|
|
23
23
|
import { decide } from "./lifecycle.js";
|
|
24
24
|
import { podLifecycleConfig, podSpecOf } from "./spec.js";
|
|
25
|
+
import { podKeysOf } from "./keys.js";
|
|
25
26
|
import { hostTuning } from "../../tuning.js";
|
|
26
27
|
import { idleMsOf, revertStart } from "../../tenant/agent-versions.js";
|
|
27
28
|
export async function reconcileSandbox(ctx, sandboxId, trigger) {
|
|
@@ -69,6 +70,25 @@ export async function reconcileSandbox(ctx, sandboxId, trigger) {
|
|
|
69
70
|
current.rev === row.pod.rev &&
|
|
70
71
|
current.k8sName === row.pod.k8sName &&
|
|
71
72
|
current.desired === row.pod.desired;
|
|
73
|
+
let keys;
|
|
74
|
+
if (decision.apply) {
|
|
75
|
+
// The keyed hosts are read at every apply (ADR-142): a host bound or connected later reaches
|
|
76
|
+
// the sandbox's rules on its next start, resume or reset.
|
|
77
|
+
try {
|
|
78
|
+
keys = await podKeysOf(ctx, {
|
|
79
|
+
sandboxId,
|
|
80
|
+
...(row.ownerUserId === undefined ? {} : { owner: row.ownerUserId }),
|
|
81
|
+
...(config.limits?.network === undefined ? {} : { ceiling: config.limits.network }),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
ctx.config.logger.warn("sandbox keys unread", {
|
|
86
|
+
sandboxId,
|
|
87
|
+
message: error instanceof Error ? error.message : String(error),
|
|
88
|
+
});
|
|
89
|
+
return { retryAfterMs: decision.retryAfterMs ?? hostTuning().pods.reconcileRetryMs };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
72
92
|
// A new join token is recorded before the service writes it to the Sandbox's Secret.
|
|
73
93
|
let joinToken;
|
|
74
94
|
let rev = patch.rev ?? row.pod.rev;
|
|
@@ -110,6 +130,7 @@ export async function reconcileSandbox(ctx, sandboxId, trigger) {
|
|
|
110
130
|
harnessImage: pods.harnessImage,
|
|
111
131
|
harness: row.applied.harness,
|
|
112
132
|
...(joinToken ? { joinToken } : {}),
|
|
133
|
+
keys,
|
|
113
134
|
});
|
|
114
135
|
try {
|
|
115
136
|
await pods.client.put(row.pod.k8sName, spec);
|
|
@@ -43,5 +43,7 @@ export interface PodSpecInput {
|
|
|
43
43
|
readonly harness?: string;
|
|
44
44
|
/** The join token, when this apply rotates it. */
|
|
45
45
|
readonly joinToken?: string;
|
|
46
|
+
/** The keyed hosts (`keys.ts`), for Agent Substrate's egress. */
|
|
47
|
+
readonly keys?: PodSpec["keys"];
|
|
46
48
|
}
|
|
47
49
|
export declare function podSpecOf(input: PodSpecInput): PodSpec;
|
package/dist/vault/service.d.ts
CHANGED
|
@@ -273,4 +273,14 @@ export declare function routingOf(binding: Pick<CredentialBinding, "via" | "viaF
|
|
|
273
273
|
via?: string;
|
|
274
274
|
toolPrefix?: string;
|
|
275
275
|
};
|
|
276
|
+
/**
|
|
277
|
+
* The hosts Environment `environmentId`'s sandbox has a key for (ADR-142), each with the header
|
|
278
|
+
* the key goes in: the hosts of its live `environment_secret`s and of its applied
|
|
279
|
+
* `environment_secret` slots, filled or not, so a slot whose value is removed still has its rule
|
|
280
|
+
* and fails closed. Read in `t` from the clear binding: no value is read and no key is needed.
|
|
281
|
+
*/
|
|
282
|
+
export declare function environmentSecretHosts(t: Tx, environmentId: string): Promise<{
|
|
283
|
+
host: string;
|
|
284
|
+
header: string;
|
|
285
|
+
}[]>;
|
|
276
286
|
export {};
|
package/dist/vault/service.js
CHANGED
|
@@ -1195,3 +1195,35 @@ export function routingOf(binding, slot) {
|
|
|
1195
1195
|
const toolPrefix = routed?.toolPrefix ?? binding.toolPrefix;
|
|
1196
1196
|
return toolPrefix === undefined ? { via } : { via, toolPrefix };
|
|
1197
1197
|
}
|
|
1198
|
+
/**
|
|
1199
|
+
* The hosts Environment `environmentId`'s sandbox has a key for (ADR-142), each with the header
|
|
1200
|
+
* the key goes in: the hosts of its live `environment_secret`s and of its applied
|
|
1201
|
+
* `environment_secret` slots, filled or not, so a slot whose value is removed still has its rule
|
|
1202
|
+
* and fails closed. Read in `t` from the clear binding: no value is read and no key is needed.
|
|
1203
|
+
*/
|
|
1204
|
+
export async function environmentSecretHosts(t, environmentId) {
|
|
1205
|
+
const hosts = new Map();
|
|
1206
|
+
const add = (list, header) => {
|
|
1207
|
+
for (const host of list ?? []) {
|
|
1208
|
+
const name = host.toLowerCase();
|
|
1209
|
+
if (!hosts.has(name))
|
|
1210
|
+
hosts.set(name, header ?? ENVIRONMENT_SECRET_DEFAULT_INJECT.header);
|
|
1211
|
+
}
|
|
1212
|
+
};
|
|
1213
|
+
const slots = await appliedSlots(t, environmentId);
|
|
1214
|
+
const vault = await t.vaultOfEnvironment(environmentId);
|
|
1215
|
+
if (vault) {
|
|
1216
|
+
const live = liveFilterOf(slots);
|
|
1217
|
+
for (const row of await t.credentialsForVault(vault.id)) {
|
|
1218
|
+
if (row.type !== "environment_secret" || !live(row))
|
|
1219
|
+
continue;
|
|
1220
|
+
const binding = credentialBinding(row);
|
|
1221
|
+
add(binding.allowedHosts, binding.inject?.header);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
for (const slot of Object.values(slots ?? {})) {
|
|
1225
|
+
if (slot.type === "environment_secret")
|
|
1226
|
+
add(slot.allowedHosts, slot.inject?.header);
|
|
1227
|
+
}
|
|
1228
|
+
return [...hosts.entries()].map(([host, header]) => ({ host, header }));
|
|
1229
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const RUNTIME_VERSION = "0.
|
|
1
|
+
export declare const RUNTIME_VERSION = "0.49.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.
|
|
3
|
+
export const RUNTIME_VERSION = "0.49.0-beta";
|
package/package.json
CHANGED