@nylorun/runtime 0.21.1-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 +19 -0
- 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/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/gates.js +11 -0
- 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/schema.d.ts +2 -2
- package/dist/store/postgres/schema.js +1 -1
- package/dist/tenant/sessions.js +26 -0
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.0-beta
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 212364b: **Credentials for skills (R2c).** A skill's CLI in a pod sandbox authenticates with a vault credential whose value never enters the sandbox.
|
|
8
|
+
|
|
9
|
+
- `@nylorun/core`: vault credentials gain `environment_secret` (`secretName`, `secretValue`, `allowedHosts`, optional `inject: { header, format }`) and `environment_variable` (`variableName`, `variableValue`), in create, rotate and `CredentialInfo` (whose `binding.url` is now optional). `ENVIRONMENT_SECRET_SENTINEL` (`nylorun-managed`), `ENVIRONMENT_SECRET_DEFAULT_INJECT`, `isInjectFormat`, `renderInjectFormat`, `EnvironmentNameSchema` and `RESERVED_ENVIRONMENT_NAMES` are exported; `ERROR_CODES` gains `credential_conflict`; the Harness API's `RunRouting.sandbox` gains `environment`.
|
|
10
|
+
- `@nylorun/runtime`: every sandbox command gets each secret's name set to `nylorun-managed` and each variable's value. egress-gate terminates TLS for port 443 of a host an `environment_secret` of the sandbox's sessions is bound to, with a fresh leaf signed by the installation's egress CA (held by keys; its certificate reaches the pod in the join answer, and the engine points `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO` and `AWS_CA_BUNDLE` at a bundle with it), and sets the credential's header on every request from the vault, uncached. Every other host stays an opaque tunnel. Session open answers `409 credential_conflict` for two credentials with one variable name or one host, or two owners' secrets on one sandbox. No protocol change.
|
|
11
|
+
- `@nylorun/studio`: the Credentials page lists shell credentials (their variable and hosts) and deletes them; create and rotate them with the Management API.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Pin core to the tested release.
|
|
16
|
+
- Pin harness to the tested release.
|
|
17
|
+
- Updated dependencies [212364b]
|
|
18
|
+
- Updated dependencies
|
|
19
|
+
- @nylorun/core@0.19.0-beta
|
|
20
|
+
- @nylorun/harness@0.25.1-beta
|
|
21
|
+
|
|
3
22
|
## 0.21.1-beta
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
|
@@ -57,7 +57,7 @@ export function localBackend(options) {
|
|
|
57
57
|
return reject(signal.reason ?? new Error("Turn cancelled"));
|
|
58
58
|
const child = spawn("/bin/sh", ["-c", request.command], {
|
|
59
59
|
cwd: request.cwd,
|
|
60
|
-
env: { ...env, ...(options.proxyEnv?.() ?? {}) },
|
|
60
|
+
env: { ...env, ...(request.env ?? {}), ...(options.proxyEnv?.() ?? {}) },
|
|
61
61
|
stdio: ["ignore", "pipe", "pipe"],
|
|
62
62
|
// Its own process group, so a timeout or a cancel kills everything it started.
|
|
63
63
|
detached: true,
|
package/dist/api/components.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare const Rejected: z.ZodObject<{
|
|
|
10
10
|
status: z.ZodLiteral<"rejected">;
|
|
11
11
|
code: z.ZodEnum<{
|
|
12
12
|
connection_missing: "connection_missing";
|
|
13
|
+
credential_conflict: "credential_conflict";
|
|
13
14
|
credential_invalid: "credential_invalid";
|
|
14
15
|
credential_rejected: "credential_rejected";
|
|
15
16
|
credential_required: "credential_required";
|
|
@@ -8376,6 +8377,19 @@ export declare const CreateCredentialRequest: z.ZodObject<{
|
|
|
8376
8377
|
type: z.ZodLiteral<"headers">;
|
|
8377
8378
|
url: z.ZodString;
|
|
8378
8379
|
headers: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
8380
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
8381
|
+
type: z.ZodLiteral<"environment_secret">;
|
|
8382
|
+
secretName: z.ZodString;
|
|
8383
|
+
secretValue: z.ZodString;
|
|
8384
|
+
allowedHosts: z.ZodArray<z.ZodString>;
|
|
8385
|
+
inject: z.ZodOptional<z.ZodObject<{
|
|
8386
|
+
header: z.ZodString;
|
|
8387
|
+
format: z.ZodString;
|
|
8388
|
+
}, z.core.$strict>>;
|
|
8389
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
8390
|
+
type: z.ZodLiteral<"environment_variable">;
|
|
8391
|
+
variableName: z.ZodString;
|
|
8392
|
+
variableValue: z.ZodString;
|
|
8379
8393
|
}, z.core.$strict>], "type">;
|
|
8380
8394
|
}, z.core.$strict>;
|
|
8381
8395
|
export declare const CredentialInfo: z.ZodObject<{
|
|
@@ -8384,10 +8398,20 @@ export declare const CredentialInfo: z.ZodObject<{
|
|
|
8384
8398
|
name: z.ZodString;
|
|
8385
8399
|
type: z.ZodEnum<{
|
|
8386
8400
|
bearer: "bearer";
|
|
8401
|
+
environment_secret: "environment_secret";
|
|
8402
|
+
environment_variable: "environment_variable";
|
|
8387
8403
|
headers: "headers";
|
|
8388
8404
|
}>;
|
|
8389
8405
|
binding: z.ZodObject<{
|
|
8390
|
-
url: z.ZodString
|
|
8406
|
+
url: z.ZodOptional<z.ZodString>;
|
|
8407
|
+
secretName: z.ZodOptional<z.ZodString>;
|
|
8408
|
+
allowedHosts: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
8409
|
+
inject: z.ZodOptional<z.ZodObject<{
|
|
8410
|
+
header: z.ZodString;
|
|
8411
|
+
format: z.ZodString;
|
|
8412
|
+
}, z.core.$strict>>;
|
|
8413
|
+
variableName: z.ZodOptional<z.ZodString>;
|
|
8414
|
+
variableValue: z.ZodOptional<z.ZodString>;
|
|
8391
8415
|
}, z.core.$strict>;
|
|
8392
8416
|
headerNames: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
8393
8417
|
via: z.ZodOptional<z.ZodString>;
|
|
@@ -8404,10 +8428,20 @@ export declare const ListCredentialsResponse: z.ZodObject<{
|
|
|
8404
8428
|
name: z.ZodString;
|
|
8405
8429
|
type: z.ZodEnum<{
|
|
8406
8430
|
bearer: "bearer";
|
|
8431
|
+
environment_secret: "environment_secret";
|
|
8432
|
+
environment_variable: "environment_variable";
|
|
8407
8433
|
headers: "headers";
|
|
8408
8434
|
}>;
|
|
8409
8435
|
binding: z.ZodObject<{
|
|
8410
|
-
url: z.ZodString
|
|
8436
|
+
url: z.ZodOptional<z.ZodString>;
|
|
8437
|
+
secretName: z.ZodOptional<z.ZodString>;
|
|
8438
|
+
allowedHosts: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
8439
|
+
inject: z.ZodOptional<z.ZodObject<{
|
|
8440
|
+
header: z.ZodString;
|
|
8441
|
+
format: z.ZodString;
|
|
8442
|
+
}, z.core.$strict>>;
|
|
8443
|
+
variableName: z.ZodOptional<z.ZodString>;
|
|
8444
|
+
variableValue: z.ZodOptional<z.ZodString>;
|
|
8411
8445
|
}, z.core.$strict>;
|
|
8412
8446
|
headerNames: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
8413
8447
|
via: z.ZodOptional<z.ZodString>;
|
|
@@ -8435,6 +8469,12 @@ export declare const RotateCredentialRequest: z.ZodObject<{
|
|
|
8435
8469
|
}, z.core.$strict>>>;
|
|
8436
8470
|
type: z.ZodLiteral<"headers">;
|
|
8437
8471
|
headers: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
8472
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
8473
|
+
type: z.ZodLiteral<"environment_secret">;
|
|
8474
|
+
secretValue: z.ZodString;
|
|
8475
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
8476
|
+
type: z.ZodLiteral<"environment_variable">;
|
|
8477
|
+
variableValue: z.ZodString;
|
|
8438
8478
|
}, z.core.$strict>], "type">;
|
|
8439
8479
|
}, z.core.$strict>;
|
|
8440
8480
|
export declare const DeletedResponse: z.ZodObject<{
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Duplex } from "node:stream";
|
|
2
|
+
import type { Logger } from "../tenant/types.js";
|
|
3
|
+
/** What the vault answers for one request to a host a credential may be bound to. */
|
|
4
|
+
export type EgressCredentialRelease = {
|
|
5
|
+
readonly status: "none";
|
|
6
|
+
} | {
|
|
7
|
+
readonly status: "released";
|
|
8
|
+
readonly header: string;
|
|
9
|
+
readonly value: string;
|
|
10
|
+
readonly credentialId: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly status: "refused";
|
|
13
|
+
readonly reason: string;
|
|
14
|
+
};
|
|
15
|
+
export interface EgressCredentials {
|
|
16
|
+
/** Whether an `environment_secret` of a live session on the sandbox is bound to `host`. */
|
|
17
|
+
bound(sandboxId: string, host: string): Promise<boolean>;
|
|
18
|
+
/** The header to set on one request, read from the vault each time. */
|
|
19
|
+
release(sandboxId: string, host: string): Promise<EgressCredentialRelease>;
|
|
20
|
+
/** A fresh leaf for `host`: its key, made here, and its certificate, signed by keys. */
|
|
21
|
+
leaf(host: string): Promise<{
|
|
22
|
+
readonly key: string;
|
|
23
|
+
readonly cert: string;
|
|
24
|
+
}>;
|
|
25
|
+
/** Tests: the CA upstream servers are verified against, instead of the public roots. */
|
|
26
|
+
readonly upstreamCa?: string;
|
|
27
|
+
/** Tests: the port requests are sent to, instead of 443. */
|
|
28
|
+
readonly upstreamPort?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface CredentialTerminator {
|
|
31
|
+
/**
|
|
32
|
+
* Answers the CONNECT, terminates TLS for `host` and serves its requests, sending each to one
|
|
33
|
+
* of `addresses` (already checked) and nowhere else.
|
|
34
|
+
*/
|
|
35
|
+
terminate(client: Duplex, target: {
|
|
36
|
+
readonly sandboxId: string;
|
|
37
|
+
readonly host: string;
|
|
38
|
+
readonly addresses: readonly {
|
|
39
|
+
address: string;
|
|
40
|
+
family: number;
|
|
41
|
+
}[];
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export declare function credentialTerminator(options: {
|
|
45
|
+
readonly credentials: EgressCredentials;
|
|
46
|
+
readonly logger: Logger;
|
|
47
|
+
readonly idleMs: number;
|
|
48
|
+
}): CredentialTerminator;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* egress-gate's credential path (R2c, D50): how a skill's CLI authenticates while the secret
|
|
3
|
+
* stays out of the sandbox. A CONNECT to port 443 of a host that an `environment_secret` of a
|
|
4
|
+
* live session on the sandbox is bound to (`allowedHosts`) is not tunnelled: the gate answers
|
|
5
|
+
* 200, terminates TLS with a fresh leaf for that host signed by the installation's egress CA
|
|
6
|
+
* (keys holds its key; the pod trusts its certificate, `harness/pod.ts`), and reads HTTP/1.1
|
|
7
|
+
* requests. For each request it reads the vault (one Postgres query, no cache, so a rotation
|
|
8
|
+
* applies to the next request), sets the credential's header (replacing any of that name, such
|
|
9
|
+
* as the `nylorun-managed` sentinel the CLI sent), and forwards it over TLS to the address the
|
|
10
|
+
* gate already checked, verifying the host's certificate by name against the public roots.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here knows a tool: the header name and its format are the credential's. A release the
|
|
13
|
+
* vault refuses (two secrets for one host, an unreadable value) answers 502 and is logged, never
|
|
14
|
+
* as an event; a value never is. Upgrades (WebSocket) and HTTP/2 are not served on this path.
|
|
15
|
+
*/
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import { Agent, request } from "node:https";
|
|
18
|
+
import { createSecureContext, TLSSocket } from "node:tls";
|
|
19
|
+
/** Headers that describe one hop and are never forwarded. */
|
|
20
|
+
const HOP_BY_HOP = new Set([
|
|
21
|
+
"connection",
|
|
22
|
+
"keep-alive",
|
|
23
|
+
"proxy-authenticate",
|
|
24
|
+
"proxy-authorization",
|
|
25
|
+
"proxy-connection",
|
|
26
|
+
"te",
|
|
27
|
+
"trailer",
|
|
28
|
+
"transfer-encoding",
|
|
29
|
+
"upgrade",
|
|
30
|
+
]);
|
|
31
|
+
/** `rawHeaders` without hop-by-hop headers and `drop`, as lowercase names to values. */
|
|
32
|
+
function forwardable(raw, drop) {
|
|
33
|
+
const headers = {};
|
|
34
|
+
for (let i = 0; i + 1 < raw.length; i += 2) {
|
|
35
|
+
const name = raw[i].toLowerCase();
|
|
36
|
+
if (HOP_BY_HOP.has(name) || name === drop)
|
|
37
|
+
continue;
|
|
38
|
+
(headers[name] ??= []).push(raw[i + 1]);
|
|
39
|
+
}
|
|
40
|
+
return headers;
|
|
41
|
+
}
|
|
42
|
+
function reply(res, status, message) {
|
|
43
|
+
if (res.headersSent) {
|
|
44
|
+
res.destroy();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const body = `egress-gate: ${message}\n`;
|
|
48
|
+
res.writeHead(status, { "content-type": "text/plain; charset=utf-8", "content-length": Buffer.byteLength(body) });
|
|
49
|
+
res.end(body);
|
|
50
|
+
}
|
|
51
|
+
export function credentialTerminator(options) {
|
|
52
|
+
const { credentials, logger } = options;
|
|
53
|
+
const terminated = new WeakMap();
|
|
54
|
+
const http = createServer((req, res) => {
|
|
55
|
+
forward(req, res).catch((error) => {
|
|
56
|
+
logger.error("egress_forward_failed", { error: error instanceof Error ? error.message : String(error) });
|
|
57
|
+
reply(res, 502, "the request failed");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
http.keepAliveTimeout = options.idleMs;
|
|
61
|
+
http.headersTimeout = 60_000;
|
|
62
|
+
// Uploads may be long; the client socket's idle timeout still applies.
|
|
63
|
+
http.requestTimeout = 0;
|
|
64
|
+
http.on("upgrade", (req, socket) => {
|
|
65
|
+
const at = terminated.get(req.socket);
|
|
66
|
+
logger.warn("egress_refused", { status: 501, reason: "upgrade_refused", sandboxId: at?.sandboxId, host: at?.host });
|
|
67
|
+
socket.destroy();
|
|
68
|
+
});
|
|
69
|
+
http.on("clientError", (_error, socket) => socket.destroy());
|
|
70
|
+
async function forward(req, res) {
|
|
71
|
+
const at = terminated.get(req.socket);
|
|
72
|
+
if (!at) {
|
|
73
|
+
res.destroy();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const { sandboxId, host } = at;
|
|
77
|
+
const release = await credentials.release(sandboxId, host);
|
|
78
|
+
if (release.status === "refused") {
|
|
79
|
+
logger.warn("egress_credential_refused", { status: 502, reason: release.reason, sandboxId, host });
|
|
80
|
+
req.resume();
|
|
81
|
+
return reply(res, 502, `the credential for ${host} could not be used`);
|
|
82
|
+
}
|
|
83
|
+
const header = release.status === "released" ? release.header.toLowerCase() : undefined;
|
|
84
|
+
const headers = forwardable(req.rawHeaders, header);
|
|
85
|
+
if (release.status === "released")
|
|
86
|
+
headers[header] = [release.value];
|
|
87
|
+
const upstream = request({
|
|
88
|
+
host,
|
|
89
|
+
port: credentials.upstreamPort ?? 443,
|
|
90
|
+
servername: host,
|
|
91
|
+
method: req.method,
|
|
92
|
+
path: req.url,
|
|
93
|
+
headers,
|
|
94
|
+
agent: at.agent,
|
|
95
|
+
});
|
|
96
|
+
upstream.on("response", (response) => {
|
|
97
|
+
res.writeHead(response.statusCode ?? 502, response.statusMessage, forwardable(response.rawHeaders));
|
|
98
|
+
response.pipe(res);
|
|
99
|
+
response.on("error", () => res.destroy());
|
|
100
|
+
});
|
|
101
|
+
upstream.on("error", (error) => {
|
|
102
|
+
logger.warn("egress_upstream_failed", { status: 502, sandboxId, host, code: error.code });
|
|
103
|
+
reply(res, 502, `could not reach ${host}`);
|
|
104
|
+
});
|
|
105
|
+
req.on("error", () => upstream.destroy());
|
|
106
|
+
res.on("close", () => {
|
|
107
|
+
if (!res.writableFinished)
|
|
108
|
+
upstream.destroy();
|
|
109
|
+
});
|
|
110
|
+
req.pipe(upstream);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
async terminate(client, target) {
|
|
114
|
+
const { sandboxId, host, addresses } = target;
|
|
115
|
+
const leaf = await credentials.leaf(host);
|
|
116
|
+
if (client.destroyed)
|
|
117
|
+
return;
|
|
118
|
+
// Requests go to the addresses the gate checked, never to the name resolved again.
|
|
119
|
+
const lookup = (_name, lookupOptions, callback) => {
|
|
120
|
+
if (lookupOptions.all)
|
|
121
|
+
callback(null, addresses);
|
|
122
|
+
else
|
|
123
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
124
|
+
};
|
|
125
|
+
const agent = new Agent({
|
|
126
|
+
keepAlive: true,
|
|
127
|
+
maxSockets: 8,
|
|
128
|
+
lookup,
|
|
129
|
+
...(credentials.upstreamCa ? { ca: credentials.upstreamCa } : {}),
|
|
130
|
+
});
|
|
131
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
132
|
+
const tls = new TLSSocket(client, {
|
|
133
|
+
isServer: true,
|
|
134
|
+
secureContext: createSecureContext({ key: leaf.key, cert: leaf.cert }),
|
|
135
|
+
ALPNProtocols: ["http/1.1"],
|
|
136
|
+
});
|
|
137
|
+
terminated.set(tls, { sandboxId, host, agent });
|
|
138
|
+
tls.on("error", () => tls.destroy());
|
|
139
|
+
tls.on("close", () => {
|
|
140
|
+
agent.destroy();
|
|
141
|
+
client.destroy();
|
|
142
|
+
});
|
|
143
|
+
logger.info("egress_terminated", { sandboxId, host });
|
|
144
|
+
http.emit("connection", tls);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
package/dist/gates/egress.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type Socket } from "node:net";
|
|
|
3
3
|
import type { EgressTokenVerdict } from "../sandbox/egress-token.js";
|
|
4
4
|
import type { SessionStore } from "../store/types.js";
|
|
5
5
|
import type { Logger } from "../tenant/types.js";
|
|
6
|
+
import { type EgressCredentials } from "./egress-credentials.js";
|
|
6
7
|
/**
|
|
7
8
|
* What egress-gate reads of a sandbox: its current host epoch, the pod that joined at it, and
|
|
8
9
|
* its spec's `network.allow`; `undefined` when it is gone, deleted, lost, expired or not a pod.
|
|
@@ -46,6 +47,8 @@ export interface StartEgressGateOptions {
|
|
|
46
47
|
readonly blocked?: (address: string) => boolean;
|
|
47
48
|
/** Tests replace how the checked address is dialled. */
|
|
48
49
|
readonly dial?: (address: string, port: number) => Socket;
|
|
50
|
+
/** The vault and keys, for hosts an `environment_secret` is bound to (R2c). None: tunnels only. */
|
|
51
|
+
readonly credentials?: EgressCredentials;
|
|
49
52
|
}
|
|
50
53
|
export interface EgressGate {
|
|
51
54
|
/** Where the gate listens, e.g. `http://0.0.0.0:4200`. */
|
package/dist/gates/egress.js
CHANGED
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* (cloud metadata included), multicast or reserved, else 502 or 403;
|
|
18
18
|
* 6. the gate connects to that checked address (never to the name again) and pipes.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
20
|
+
* A tunnel is opaque bytes, with one exception (R2c, D50): port 443 of a host that an
|
|
21
|
+
* `environment_secret` of a live session on the sandbox is bound to. There the gate terminates
|
|
22
|
+
* TLS and sets the credential's header on every request (`egress-credentials.ts`), still sending
|
|
23
|
+
* only to the checked address. Every other host gets the tunnel. Any other request
|
|
21
24
|
* (an absolute-form plain HTTP request included) is answered 405. Refusals are logged, never as
|
|
22
25
|
* events; the token never is. Tunnels close after `idleMs` without traffic in either direction.
|
|
23
26
|
*/
|
|
@@ -27,6 +30,7 @@ import { BlockList, connect, isIP } from "node:net";
|
|
|
27
30
|
import { isSandboxHostPattern } from "@nylorun/core/define";
|
|
28
31
|
import { isPrivateAddress } from "../tenant/outbound.js";
|
|
29
32
|
import { bindListener } from "../host/http.js";
|
|
33
|
+
import { credentialTerminator } from "./egress-credentials.js";
|
|
30
34
|
/** Ports a tunnel may open. */
|
|
31
35
|
export const EGRESS_PORTS = [443, 80];
|
|
32
36
|
/** Tunnels one sandbox may hold open at once. */
|
|
@@ -151,6 +155,9 @@ export async function startEgressGate(options) {
|
|
|
151
155
|
cache.delete(cache.keys().next().value);
|
|
152
156
|
return value;
|
|
153
157
|
}
|
|
158
|
+
const terminator = options.credentials
|
|
159
|
+
? credentialTerminator({ credentials: options.credentials, logger, idleMs })
|
|
160
|
+
: undefined;
|
|
154
161
|
const open = new Map();
|
|
155
162
|
const tunnels = new Set();
|
|
156
163
|
async function onConnect(req, client, head) {
|
|
@@ -209,6 +216,12 @@ export async function startEgressGate(options) {
|
|
|
209
216
|
return refuse(403, "address_refused", { sandboxId }, `${host} resolves only to addresses tunnels may not reach`);
|
|
210
217
|
if (client.destroyed)
|
|
211
218
|
return;
|
|
219
|
+
if (terminator && port === 443 && (await options.credentials.bound(sandboxId, host))) {
|
|
220
|
+
// A client that spoke before the 200 is not one this path serves.
|
|
221
|
+
if (head.length > 0)
|
|
222
|
+
return refuse(403, "early_data", { sandboxId }, "wait for the CONNECT answer before sending");
|
|
223
|
+
return terminator.terminate(client, { sandboxId, host, addresses: allowed });
|
|
224
|
+
}
|
|
212
225
|
const upstream = await dialFirst(allowed.map((entry) => entry.address), port);
|
|
213
226
|
if (!upstream)
|
|
214
227
|
return refuse(502, "connect_failed", { sandboxId }, `could not connect to ${host}:${port}`);
|
|
@@ -34,6 +34,10 @@ export interface TenantVault {
|
|
|
34
34
|
authorizeMcp(sessionId: string, request: McpCredentialRequest): Promise<AuthorizeResult>;
|
|
35
35
|
/** Vault writes and token signing with the Tenant's vault key (the keys service, F4.2). */
|
|
36
36
|
keys(): Keys;
|
|
37
|
+
/** Whether a live session on the sandbox has an `environment_secret` bound to `host` (R2c). */
|
|
38
|
+
environmentSecretBound(sandboxId: string, host: string): Promise<boolean>;
|
|
39
|
+
/** The header an `environment_secret` sets on one request to `host` (R2c). */
|
|
40
|
+
releaseEnvironmentSecret(sandboxId: string, host: string): ReturnType<VaultService["releaseEnvironmentSecret"]>;
|
|
37
41
|
}
|
|
38
42
|
export interface TenantVaults {
|
|
39
43
|
/**
|
|
@@ -52,6 +52,8 @@ export function createTenantVaults(options) {
|
|
|
52
52
|
session,
|
|
53
53
|
authorizeMcp: (sessionId, request) => authorizeSessionMcp(credentials, session, sessionId, request),
|
|
54
54
|
keys: () => keys,
|
|
55
|
+
environmentSecretBound: (sandboxId, host) => credentials.environmentSecretBound(sandboxId, host),
|
|
56
|
+
releaseEnvironmentSecret: (sandboxId, host) => credentials.releaseEnvironmentSecret({ sandboxId, host }),
|
|
55
57
|
};
|
|
56
58
|
}
|
|
57
59
|
/** The database's Tenant, once its schema is at this build's version. */
|
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";
|
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
|
}
|
|
@@ -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
|
+
}
|