@nylorun/runtime 0.32.0-beta → 0.34.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 +36 -0
- package/dist/api/ag-ui/routes.js +4 -2
- package/dist/api/ag-ui/translate.js +3 -1
- package/dist/api/components.d.ts +35 -0
- package/dist/api/components.js +3 -1
- package/dist/api/http/routes/sessions.js +63 -2
- package/dist/api/openapi.js +3 -0
- package/dist/gates/credential-provider.d.ts +42 -0
- package/dist/gates/credential-provider.js +140 -0
- package/dist/gates/egress-credentials.d.ts +12 -0
- package/dist/gates/egress-credentials.js +60 -6
- package/dist/gates/egress.d.ts +14 -1
- package/dist/gates/egress.js +67 -19
- package/dist/gates/handler.js +2 -2
- package/dist/gates/in-process.d.ts +4 -3
- package/dist/gates/in-process.js +11 -5
- package/dist/gates/tenant-vaults.d.ts +4 -3
- package/dist/gates/tenant-vaults.js +2 -2
- package/dist/harness-api/start.js +30 -3
- package/dist/host/gates.d.ts +8 -1
- package/dist/host/gates.js +6 -1
- package/dist/host/main.js +17 -1
- package/dist/host/stack-config.d.ts +17 -1
- package/dist/host/stack-config.js +47 -1
- package/dist/management-openapi.json +61 -5
- package/dist/model/descriptor.d.ts +5 -4
- package/dist/model/descriptor.js +4 -3
- package/dist/model/pi-model.js +17 -1
- package/dist/openapi.json +516 -2
- package/dist/tenant/context.d.ts +12 -1
- package/dist/tenant/runtime.d.ts +6 -0
- package/dist/tenant/runtime.js +1 -0
- package/dist/tenant/session-model.d.ts +16 -0
- package/dist/tenant/session-model.js +46 -0
- package/dist/tuning.d.ts +6 -0
- package/dist/tuning.js +14 -0
- package/dist/vault/host-identity.d.ts +24 -0
- package/dist/vault/host-identity.js +35 -0
- package/dist/vault/host-model.d.ts +9 -5
- package/dist/vault/host-model.js +30 -19
- package/dist/vault/service.d.ts +18 -2
- package/dist/vault/service.js +52 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -6
package/dist/gates/egress.d.ts
CHANGED
|
@@ -3,17 +3,23 @@ import { type Socket } from "node:net";
|
|
|
3
3
|
import type { EgressRunningTurn, AgentEventPayload } from "@nylorun/core/contracts";
|
|
4
4
|
import type { EgressTokenVerdict } from "../sandbox/egress-token.js";
|
|
5
5
|
import type { SessionStore } from "../store/types.js";
|
|
6
|
+
import type { CredentialProvider } from "./credential-provider.js";
|
|
6
7
|
import type { Logger } from "../tenant/types.js";
|
|
7
8
|
import { type EgressCredentials } from "./egress-credentials.js";
|
|
8
9
|
/**
|
|
9
10
|
* What egress-gate reads of a sandbox: its current host epoch, the pod that joined at it, and
|
|
10
11
|
* its spec's `network.allow`; `undefined` when it is gone, deleted, lost, expired or not a pod.
|
|
12
|
+
* For an Agent with an owner guard, also the owner and the Tenant's egress ceiling
|
|
13
|
+
* (`environments.config`'s `limits.network`) when the Tenant sets one: what the credential
|
|
14
|
+
* provider is asked about and kept inside.
|
|
11
15
|
*/
|
|
12
16
|
export interface EgressSandboxes {
|
|
13
17
|
live(sandboxId: string): Promise<{
|
|
14
18
|
readonly epoch: number;
|
|
15
19
|
readonly podUid?: string;
|
|
16
20
|
readonly allow: readonly string[];
|
|
21
|
+
readonly owner?: string;
|
|
22
|
+
readonly ceiling?: readonly string[];
|
|
17
23
|
} | undefined>;
|
|
18
24
|
}
|
|
19
25
|
/** One connection's record (R7.3, D67): `agent.egress`'s payload. */
|
|
@@ -63,6 +69,11 @@ export interface StartEgressGateOptions {
|
|
|
63
69
|
readonly credentials?: EgressCredentials;
|
|
64
70
|
/** Where connections are recorded (R7.3). None: they are only logged. */
|
|
65
71
|
readonly record?: EgressRecord;
|
|
72
|
+
/**
|
|
73
|
+
* The outside credential provider (`NYLORUN_CREDENTIAL_PROVIDER_URL`): the hosts it lists for an
|
|
74
|
+
* Agent's owner pass on 443 and are handed to it. Needs `credentials` (the leaf). None: off.
|
|
75
|
+
*/
|
|
76
|
+
readonly provider?: CredentialProvider;
|
|
66
77
|
}
|
|
67
78
|
export interface EgressGate {
|
|
68
79
|
/** Where the gate listens, e.g. `http://0.0.0.0:4200`. */
|
|
@@ -84,7 +95,9 @@ export declare function startEgressGate(options: StartEgressGateOptions): Promis
|
|
|
84
95
|
/**
|
|
85
96
|
* `EgressSandboxes` over the Tenant's `sandbox_resources` (one indexed read): a pod sandbox's
|
|
86
97
|
* host epoch, the pod that joined at it and its spec's allowlist; nothing for a virtual
|
|
87
|
-
* Environment, or one deleted or lost, as `staleHost` judges a host token.
|
|
98
|
+
* Environment, or one deleted or lost, as `staleHost` judges a host token. An Agent with an
|
|
99
|
+
* owner guard also gets its owner and, when the Tenant sets one, its egress ceiling (one more
|
|
100
|
+
* read, of the Tenant's `environments.config`).
|
|
88
101
|
*/
|
|
89
102
|
export declare function storeEgressSandboxes(store: SessionStore): EgressSandboxes;
|
|
90
103
|
/**
|
package/dist/gates/egress.js
CHANGED
|
@@ -11,17 +11,21 @@
|
|
|
11
11
|
* 2. its sandbox is live and its host epoch and pod are the token's (`EgressSandboxes.live`),
|
|
12
12
|
* else 407;
|
|
13
13
|
* 3. the port is 443 or 80, the target is a host name and no IP literal, and it matches the spec's
|
|
14
|
-
* `network.allow` exactly or by `*.suffix`,
|
|
14
|
+
* `network.allow` exactly or by `*.suffix`, or (443 only) the outside credential provider lists
|
|
15
|
+
* it for the Agent's owner and the Tenant's egress ceiling holds it, else 403;
|
|
15
16
|
* 4. the sandbox has fewer than `maxPerSandbox` tunnels open, else 429;
|
|
16
17
|
* 5. the name resolves, and to at least one address that is not private, loopback, link-local
|
|
17
18
|
* (cloud metadata included), multicast or reserved, else 502 or 403;
|
|
18
19
|
* 6. the gate connects to that checked address (never to the name again) and pipes.
|
|
19
20
|
*
|
|
20
|
-
* A tunnel is opaque bytes, with
|
|
21
|
+
* A tunnel is opaque bytes, with two exceptions. First (R2c, D50): port 443 of a host that an
|
|
21
22
|
* `environment_secret` of a live session on the sandbox is bound to. There the gate terminates
|
|
22
23
|
* TLS and sets the credential's header on every request (`egress-credentials.ts`), still sending
|
|
23
|
-
* only to the checked address.
|
|
24
|
-
* (
|
|
24
|
+
* only to the checked address. Second, port 443 of a host the credential provider lists
|
|
25
|
+
* (`credential-provider.ts`) and no secret binds: terminated the same way, its requests are handed
|
|
26
|
+
* to the provider's forward route, which adds the key; the vault's binding wins where both apply.
|
|
27
|
+
* Every other host gets the tunnel. Any other request (an absolute-form plain HTTP request
|
|
28
|
+
* included) is answered 405. Refusals are logged; the token
|
|
25
29
|
* never is. Tunnels close after `idleMs` without traffic in either direction.
|
|
26
30
|
*
|
|
27
31
|
* Every connection to a named host on a live token is recorded once (R7.3, D67), when it closes
|
|
@@ -40,6 +44,8 @@ import { createServer } from "node:http";
|
|
|
40
44
|
import { BlockList, connect, isIP } from "node:net";
|
|
41
45
|
import { isSandboxHostPattern } from "@nylorun/core/define";
|
|
42
46
|
import { isPrivateAddress } from "../tenant/outbound.js";
|
|
47
|
+
import { readSandboxConfig } from "../sandbox/tenant-config.js";
|
|
48
|
+
import { withinCeiling } from "../sandbox/resolve.js";
|
|
43
49
|
import { bindListener } from "../host/http.js";
|
|
44
50
|
import { hostTuning } from "../tuning.js";
|
|
45
51
|
import { CONNECT_ESTABLISHED, credentialTerminator, } from "./egress-credentials.js";
|
|
@@ -212,8 +218,25 @@ export async function startEgressGate(options) {
|
|
|
212
218
|
return value;
|
|
213
219
|
}
|
|
214
220
|
const terminator = options.credentials
|
|
215
|
-
? credentialTerminator({
|
|
221
|
+
? credentialTerminator({
|
|
222
|
+
credentials: options.credentials,
|
|
223
|
+
logger,
|
|
224
|
+
idleMs,
|
|
225
|
+
...(options.provider ? { provider: options.provider } : {}),
|
|
226
|
+
})
|
|
216
227
|
: undefined;
|
|
228
|
+
const provider = terminator ? options.provider : undefined;
|
|
229
|
+
/**
|
|
230
|
+
* Whether the credential provider lists `host` for the sandbox's owner, inside the Tenant's
|
|
231
|
+
* ceiling: only on 443, where the gate terminates TLS, and never for an Agent without an owner.
|
|
232
|
+
*/
|
|
233
|
+
async function providerLists(sandboxId, sandbox, host, port) {
|
|
234
|
+
if (!provider || port !== 443 || sandbox.owner === undefined)
|
|
235
|
+
return false;
|
|
236
|
+
if (sandbox.ceiling && !withinCeiling(host, sandbox.ceiling))
|
|
237
|
+
return false;
|
|
238
|
+
return (await provider.hosts(sandboxId, sandbox.owner)).includes(host);
|
|
239
|
+
}
|
|
217
240
|
const open = new Map();
|
|
218
241
|
const tunnels = new Set();
|
|
219
242
|
/** Each connection's record, once its Environment and target are known. */
|
|
@@ -257,7 +280,9 @@ export async function startEgressGate(options) {
|
|
|
257
280
|
return refuse(403, "port_refused", { sandboxId }, `port ${port} is not allowed; tunnels open to 443 or 80 only`);
|
|
258
281
|
if (isIP(host.replace(/^\[|\]$/g, "")) !== 0 || !isSandboxHostPattern(host) || host.startsWith("*."))
|
|
259
282
|
return refuse(403, "not_a_host_name", { sandboxId }, `${host} is not a host name; IP literals are refused`);
|
|
260
|
-
|
|
283
|
+
// A host the provider lists goes to it, even one the spec allows, unless the vault binds it.
|
|
284
|
+
const provided = await providerLists(sandboxId, sandbox, host, port);
|
|
285
|
+
if (!provided && !egressAllows(host, sandbox.allow))
|
|
261
286
|
return refuse(403, "host_not_allowed", { sandboxId }, `${host} is not in this sandbox's network.allow`);
|
|
262
287
|
// The turns running on the Environment as the connection opens: the read starts as the
|
|
263
288
|
// name resolves, and the 200 does not wait for it.
|
|
@@ -298,6 +323,20 @@ export async function startEgressGate(options) {
|
|
|
298
323
|
client.once("close", () => record.closed(tally));
|
|
299
324
|
return terminator.terminate(client, { sandboxId, host, addresses: allowed, tally });
|
|
300
325
|
}
|
|
326
|
+
if (terminator && provided && sandbox.owner !== undefined) {
|
|
327
|
+
record.credentialBound = true;
|
|
328
|
+
if (head.length > 0)
|
|
329
|
+
return refuse(403, "early_data", { sandboxId }, "wait for the CONNECT answer before sending");
|
|
330
|
+
const tally = { requests: 0, credentialIds: new Set() };
|
|
331
|
+
client.once("close", () => record.closed(tally));
|
|
332
|
+
return terminator.terminate(client, {
|
|
333
|
+
sandboxId,
|
|
334
|
+
host,
|
|
335
|
+
addresses: allowed,
|
|
336
|
+
tally,
|
|
337
|
+
provider: { agentId: sandboxId, owner: sandbox.owner },
|
|
338
|
+
});
|
|
339
|
+
}
|
|
301
340
|
const upstream = await dialFirst(allowed.map((entry) => entry.address), port);
|
|
302
341
|
if (!upstream)
|
|
303
342
|
return refuse(502, "connect_failed", { sandboxId }, `could not connect to ${host}:${port}`);
|
|
@@ -383,6 +422,7 @@ export async function startEgressGate(options) {
|
|
|
383
422
|
for (const tunnel of tunnels)
|
|
384
423
|
tunnel.destroy();
|
|
385
424
|
server.closeAllConnections();
|
|
425
|
+
terminator?.close();
|
|
386
426
|
});
|
|
387
427
|
return closing;
|
|
388
428
|
},
|
|
@@ -391,23 +431,31 @@ export async function startEgressGate(options) {
|
|
|
391
431
|
/**
|
|
392
432
|
* `EgressSandboxes` over the Tenant's `sandbox_resources` (one indexed read): a pod sandbox's
|
|
393
433
|
* host epoch, the pod that joined at it and its spec's allowlist; nothing for a virtual
|
|
394
|
-
* Environment, or one deleted or lost, as `staleHost` judges a host token.
|
|
434
|
+
* Environment, or one deleted or lost, as `staleHost` judges a host token. An Agent with an
|
|
435
|
+
* owner guard also gets its owner and, when the Tenant sets one, its egress ceiling (one more
|
|
436
|
+
* read, of the Tenant's `environments.config`).
|
|
395
437
|
*/
|
|
396
438
|
export function storeEgressSandboxes(store) {
|
|
397
439
|
return {
|
|
398
440
|
async live(sandboxId) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
441
|
+
return store.tx(async (t) => {
|
|
442
|
+
const sandbox = await t.environment(sandboxId);
|
|
443
|
+
const pod = sandbox?.pod;
|
|
444
|
+
if (!sandbox || sandbox.kind !== "pod" || !pod)
|
|
445
|
+
return undefined;
|
|
446
|
+
if (pod.desired === "deleted" || pod.observed === "lost")
|
|
447
|
+
return undefined;
|
|
448
|
+
const owner = sandbox.ownerUserId;
|
|
449
|
+
const ceiling = owner === undefined ? undefined : (await readSandboxConfig(t)).limits?.network;
|
|
450
|
+
return {
|
|
451
|
+
epoch: pod.hostEpoch,
|
|
452
|
+
...(pod.podUid ? { podUid: pod.podUid } : {}),
|
|
453
|
+
// The applied allowlist: a recipe change writes it at once (Q8).
|
|
454
|
+
allow: sandbox.applied.network?.allowedHosts ?? [],
|
|
455
|
+
...(owner === undefined ? {} : { owner }),
|
|
456
|
+
...(ceiling === undefined ? {} : { ceiling }),
|
|
457
|
+
};
|
|
458
|
+
});
|
|
411
459
|
},
|
|
412
460
|
};
|
|
413
461
|
}
|
package/dist/gates/handler.js
CHANGED
|
@@ -23,8 +23,8 @@ export function createModelCallHandler(options) {
|
|
|
23
23
|
request = call;
|
|
24
24
|
const gate = inProcessModelGate({
|
|
25
25
|
root: vault.root,
|
|
26
|
-
readHostModel: () => vault.readHostModel(),
|
|
27
|
-
writeHostCredential: (credential) => vault.writeHostCredential(credential),
|
|
26
|
+
readHostModel: (request) => vault.readHostModel(request.sessionId),
|
|
27
|
+
writeHostCredential: (credential, provider) => vault.writeHostCredential(credential, provider),
|
|
28
28
|
// The Tenant's Runtime limits `model*`, read for this call.
|
|
29
29
|
settings: await modelCallSettings(vault.store),
|
|
30
30
|
...(blobs ? { files: callFiles(vault.store, blobs) } : {}),
|
|
@@ -15,9 +15,10 @@ import type { ModelCaller } from "../model/call-headers.js";
|
|
|
15
15
|
export interface InProcessModelGateOptions {
|
|
16
16
|
/** The Tenant's home: `piModel` redacts secrets it finds there from failure messages. */
|
|
17
17
|
readonly root?: string;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/** The host model a call uses: its session's own model (`session-model`), else the Tenant's. */
|
|
19
|
+
readonly readHostModel: (request: ModelGateRequest) => Promise<HostModelSecret | undefined>;
|
|
20
|
+
/** Writes back a credential pi-ai refreshed (OAuth) to `provider`'s, the one the call read. */
|
|
21
|
+
readonly writeHostCredential: (credential: Credential, provider: string) => Promise<void>;
|
|
21
22
|
readonly settings?: ModelCallSettings;
|
|
22
23
|
/** Reads the files a call's prompt names (protocol 6): artifacts, for model-gate. */
|
|
23
24
|
readonly files?: (request: ModelGateRequest) => FileResolver;
|
package/dist/gates/in-process.js
CHANGED
|
@@ -45,8 +45,8 @@ export function tenantModelGate(options) {
|
|
|
45
45
|
// The Tenant's Runtime limits `model*`, read for this call.
|
|
46
46
|
const gate = inProcessModelGate({
|
|
47
47
|
root: options.root,
|
|
48
|
-
readHostModel: () => vault.readHostModel(),
|
|
49
|
-
writeHostCredential: (credential) => vault.updateHostCredential(credential),
|
|
48
|
+
readHostModel: (request) => vault.readHostModel(request.sessionId),
|
|
49
|
+
writeHostCredential: (credential, provider) => vault.updateHostCredential(credential, provider),
|
|
50
50
|
settings: await modelCallSettings(options.store),
|
|
51
51
|
...(options.blobs ? { files: callFiles(options.store, options.blobs) } : {}),
|
|
52
52
|
caller: callCaller(options.store),
|
|
@@ -71,11 +71,17 @@ export async function serve(store, gate, request, signal) {
|
|
|
71
71
|
export function inProcessModelGate(options) {
|
|
72
72
|
return {
|
|
73
73
|
async call(request, signal) {
|
|
74
|
-
// One adapter per call, as before the gate: it reads the host model when invoked
|
|
74
|
+
// One adapter per call, as before the gate: it reads the host model when invoked, and a
|
|
75
|
+
// refreshed credential goes back to the provider it read.
|
|
76
|
+
let provider;
|
|
75
77
|
const adapter = piModel({
|
|
76
78
|
...(options.root !== undefined ? { root: options.root } : {}),
|
|
77
|
-
readHostModel:
|
|
78
|
-
|
|
79
|
+
readHostModel: async () => {
|
|
80
|
+
const stored = await options.readHostModel(request);
|
|
81
|
+
provider = stored?.provider;
|
|
82
|
+
return stored;
|
|
83
|
+
},
|
|
84
|
+
writeHostCredential: (credential) => options.writeHostCredential(credential, provider),
|
|
79
85
|
...(options.settings ? { settings: options.settings } : {}),
|
|
80
86
|
...(options.files ? { files: options.files(request) } : {}),
|
|
81
87
|
...(options.caller ? { caller: () => options.caller(request) } : {}),
|
|
@@ -22,9 +22,10 @@ export interface TenantVault {
|
|
|
22
22
|
readonly store: SessionStore;
|
|
23
23
|
/** The Tenant's home: secrets found there are redacted from failure messages. */
|
|
24
24
|
readonly root: string;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
/** The host model a call uses: its session's own model (`session-model`), else the Tenant's. */
|
|
26
|
+
readHostModel(sessionId?: string): Promise<HostModelSecret | undefined>;
|
|
27
|
+
/** Writes back a credential pi-ai refreshed (OAuth) to `provider`'s, else the active one's. */
|
|
28
|
+
writeHostCredential(credential: Credential, provider?: string): Promise<void>;
|
|
28
29
|
/** A session, for the remote MCP servers and HTTP tools its pinned manifest declares. */
|
|
29
30
|
session(sessionId: string): Promise<Session | undefined>;
|
|
30
31
|
/**
|
|
@@ -47,8 +47,8 @@ export function createTenantVaults(options) {
|
|
|
47
47
|
tenantId,
|
|
48
48
|
store,
|
|
49
49
|
root: paths.home,
|
|
50
|
-
readHostModel: () => vault.readHostModel(),
|
|
51
|
-
writeHostCredential: (credential) => vault.updateHostCredential(credential),
|
|
50
|
+
readHostModel: (sessionId) => vault.readHostModel(sessionId),
|
|
51
|
+
writeHostCredential: (credential, provider) => vault.updateHostCredential(credential, provider),
|
|
52
52
|
session,
|
|
53
53
|
authorizeMcp: (sessionId, request) => authorizeSessionMcp(credentials, session, sessionId, request),
|
|
54
54
|
keys: () => keys,
|
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
* outcomes are read; then the transcript is folded from the record (P0.3). `buildTurnStart`
|
|
5
5
|
* turns that into the run's `turn.start`: the checkpoint goes without its transcript, which the
|
|
6
6
|
* harness holds at the fold's cursor or reads once.
|
|
7
|
+
*
|
|
8
|
+
* A pod Agent's sandbox variables are its vault's (R2c), then, with an outside credential
|
|
9
|
+
* provider (`gates/credential-provider.ts`) and an owner on the Agent, each variable the provider
|
|
10
|
+
* names for that owner, set to the stand-in `nylorun_managed` and never over a vault variable.
|
|
11
|
+
* The provider is asked after the transaction, and one that fails adds nothing.
|
|
7
12
|
*/
|
|
8
|
-
import { harnessLimitsOf } from "@nylorun/core/contracts";
|
|
13
|
+
import { ENVIRONMENT_SECRET_SENTINEL, harnessLimitsOf } from "@nylorun/core/contracts";
|
|
9
14
|
import { SANDBOX_WORKSPACE } from "@nylorun/core/define";
|
|
10
15
|
import { isWorkflowManifest } from "../core/flow-host.js";
|
|
11
16
|
import { sandboxWorkspaceOf } from "../sandbox/share.js";
|
|
@@ -33,6 +38,8 @@ options = {}) {
|
|
|
33
38
|
const id = lease.sessionId;
|
|
34
39
|
// Read before the transaction: the first read of the Tenant may create the key.
|
|
35
40
|
const key = options.harness ? await traceKey(ctx.store) : undefined;
|
|
41
|
+
// The pod Agent and its owner the credential provider is asked about, once the transaction ends.
|
|
42
|
+
let providerAsk;
|
|
36
43
|
const opened = await ownedTx(ctx.store, id, lease.epoch, async (t, current) => {
|
|
37
44
|
// A turn's first segment runs the version its Agent runs now (T3, D73).
|
|
38
45
|
if (options.harness && (await repinTurn(t, current)))
|
|
@@ -52,6 +59,10 @@ options = {}) {
|
|
|
52
59
|
const workspace = sandboxWorkspaceOf(current);
|
|
53
60
|
const resource = workspace.environmentId === undefined ? undefined : await t.environment(workspace.environmentId);
|
|
54
61
|
const environment = await ctx.vault.environmentVariables(t, current.environmentId);
|
|
62
|
+
providerAsk =
|
|
63
|
+
ctx.credentialProvider && resource?.kind === "pod" && resource.ownerUserId
|
|
64
|
+
? { agentId: resource.id, owner: resource.ownerUserId }
|
|
65
|
+
: undefined;
|
|
55
66
|
const sandbox = {
|
|
56
67
|
...workspace,
|
|
57
68
|
...(Object.keys(environment).length > 0 ? { environment } : {}),
|
|
@@ -82,9 +93,10 @@ options = {}) {
|
|
|
82
93
|
});
|
|
83
94
|
// The model an agent's harness decides files by, read outside the session's transaction.
|
|
84
95
|
const model = options.harness && !isWorkflowManifest(opened.current.manifest)
|
|
85
|
-
? await currentModelDescriptor(ctx)
|
|
96
|
+
? await currentModelDescriptor(ctx, opened.current.model)
|
|
86
97
|
: undefined;
|
|
87
|
-
const
|
|
98
|
+
const provided = await withProviderVariables(ctx, opened, providerAsk);
|
|
99
|
+
const started = model ? { ...provided, model } : provided;
|
|
88
100
|
// One history (P0.3): the engine resumes from the transcript folded from the record.
|
|
89
101
|
const { current } = started;
|
|
90
102
|
const cp = current.checkpoint;
|
|
@@ -101,6 +113,21 @@ options = {}) {
|
|
|
101
113
|
checkParity(id, transcript, transcriptOf(cp.state));
|
|
102
114
|
return { ...started, transcript, cursor };
|
|
103
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* `opened` with the credential provider's variables for `ask` added to its sandbox's environment
|
|
118
|
+
* as the stand-in, under the vault's own; `opened` itself when there are none.
|
|
119
|
+
*/
|
|
120
|
+
async function withProviderVariables(ctx, opened, ask) {
|
|
121
|
+
if (!ask || !ctx.credentialProvider)
|
|
122
|
+
return opened;
|
|
123
|
+
const names = await ctx.credentialProvider.environment(ask.agentId, ask.owner);
|
|
124
|
+
const vault = opened.sandbox.environment ?? {};
|
|
125
|
+
const added = names.filter((name) => !Object.hasOwn(vault, name));
|
|
126
|
+
if (added.length === 0)
|
|
127
|
+
return opened;
|
|
128
|
+
const environment = { ...Object.fromEntries(added.map((name) => [name, ENVIRONMENT_SECRET_SENTINEL])), ...vault };
|
|
129
|
+
return { ...opened, sandbox: { ...opened.sandbox, environment } };
|
|
130
|
+
}
|
|
104
131
|
/** The run's `turn.start`. */
|
|
105
132
|
export function buildTurnStart(ctx, segment) {
|
|
106
133
|
const { current } = segment;
|
package/dist/host/gates.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { PostgresClient } from "../store/postgres/connect.js";
|
|
|
20
20
|
import type { Logger } from "../tenant/types.js";
|
|
21
21
|
import type { EgressConfig, GatesConfig } from "./stack-config.js";
|
|
22
22
|
import { type EgressGate, type StartEgressGateOptions } from "../gates/egress.js";
|
|
23
|
+
import { type CredentialProviderSettings } from "../gates/credential-provider.js";
|
|
23
24
|
export interface StartGatesOptions {
|
|
24
25
|
readonly gates: GatesConfig;
|
|
25
26
|
readonly logger: Logger;
|
|
@@ -71,10 +72,16 @@ export interface StartEgressOptions {
|
|
|
71
72
|
readonly vaults?: TenantVaults;
|
|
72
73
|
/** Replaces how sandboxes are read, and the gate's limits and hooks (tests). */
|
|
73
74
|
readonly gate?: Partial<Omit<StartEgressGateOptions, "listen" | "logger" | "verify">>;
|
|
75
|
+
/**
|
|
76
|
+
* The outside credential provider (`NYLORUN_CREDENTIAL_PROVIDER_*`, Host feature
|
|
77
|
+
* `credential-provider`): the hosts it lists for an Agent's owner are handed to it.
|
|
78
|
+
*/
|
|
79
|
+
readonly credentialProvider?: CredentialProviderSettings;
|
|
74
80
|
}
|
|
75
81
|
/**
|
|
76
82
|
* egress-gate (`--service egress`, F7.2): pods' CONNECT proxy, verifying egress tokens against the
|
|
77
83
|
* Tenant's public keys and reading its sandboxes from the same database as the gates, where it
|
|
78
|
-
* also records each connection (R7.3).
|
|
84
|
+
* also records each connection (R7.3). With a credential provider, the hosts it lists for an
|
|
85
|
+
* Agent's owner are terminated and handed to it.
|
|
79
86
|
*/
|
|
80
87
|
export declare function startEgress(options: StartEgressOptions): Promise<EgressGate>;
|
package/dist/host/gates.js
CHANGED
|
@@ -30,6 +30,7 @@ import { bindListener, headerValue, isAllowedRequestHost, sendRejected } from ".
|
|
|
30
30
|
import { startEgressGate, storeEgressRecord, storeEgressSandboxes, } from "../gates/egress.js";
|
|
31
31
|
import { verifyEgressToken } from "../sandbox/egress-token.js";
|
|
32
32
|
import { leafKeyPair } from "../keys/x509.js";
|
|
33
|
+
import { createCredentialProvider } from "../gates/credential-provider.js";
|
|
33
34
|
import { hostTuning } from "../tuning.js";
|
|
34
35
|
export async function startGates(options) {
|
|
35
36
|
const { gates, logger, database } = options;
|
|
@@ -159,7 +160,8 @@ export async function startGates(options) {
|
|
|
159
160
|
/**
|
|
160
161
|
* egress-gate (`--service egress`, F7.2): pods' CONNECT proxy, verifying egress tokens against the
|
|
161
162
|
* Tenant's public keys and reading its sandboxes from the same database as the gates, where it
|
|
162
|
-
* also records each connection (R7.3).
|
|
163
|
+
* also records each connection (R7.3). With a credential provider, the hosts it lists for an
|
|
164
|
+
* Agent's owner are terminated and handed to it.
|
|
163
165
|
*/
|
|
164
166
|
export async function startEgress(options) {
|
|
165
167
|
const { database } = options;
|
|
@@ -194,6 +196,9 @@ export async function startEgress(options) {
|
|
|
194
196
|
return { key: pair.privateKey, cert };
|
|
195
197
|
},
|
|
196
198
|
},
|
|
199
|
+
...(options.credentialProvider
|
|
200
|
+
? { provider: createCredentialProvider(options.credentialProvider, { logger: options.logger }) }
|
|
201
|
+
: {}),
|
|
197
202
|
...options.gate,
|
|
198
203
|
});
|
|
199
204
|
}
|
package/dist/host/main.js
CHANGED
|
@@ -63,8 +63,10 @@ import { createRunGrants } from "../tenant/run-grants.js";
|
|
|
63
63
|
import { runHarness } from "../harness/main.js";
|
|
64
64
|
import { startHarnessListener } from "../harness-api/ws-server.js";
|
|
65
65
|
import { httpSandboxesClient } from "../sandbox/pods/client.js";
|
|
66
|
+
import { createCredentialProvider } from "../gates/credential-provider.js";
|
|
66
67
|
import { retry } from "../retry.js";
|
|
67
68
|
import { hostTuning, useHostTuning } from "../tuning.js";
|
|
69
|
+
import { useHostIdentity } from "../vault/host-identity.js";
|
|
68
70
|
import { useWorkloadToken } from "../vault/workload-token.js";
|
|
69
71
|
const nodeRequire = createRequire(import.meta.url);
|
|
70
72
|
function coreVersion() {
|
|
@@ -168,6 +170,7 @@ async function runGates(stack) {
|
|
|
168
170
|
...(stack.packing ? { packing: stack.packing } : {}),
|
|
169
171
|
endpoints: describeEndpoints(stack.endpoints),
|
|
170
172
|
objectStore: stack.objectStore ? "s3" : "none",
|
|
173
|
+
...(stack.credentialProvider ? { credentialProvider: stack.credentialProvider.url } : {}),
|
|
171
174
|
});
|
|
172
175
|
warnRemovedSettings(stack, logger);
|
|
173
176
|
const database = createDatabase(stack);
|
|
@@ -188,7 +191,13 @@ async function runGates(stack) {
|
|
|
188
191
|
logger.info("gates_ready", { url: server.url });
|
|
189
192
|
}
|
|
190
193
|
if (stack.egress) {
|
|
191
|
-
const egress = await startEgress({
|
|
194
|
+
const egress = await startEgress({
|
|
195
|
+
egress: stack.egress,
|
|
196
|
+
database,
|
|
197
|
+
hostRoot: resolveHostRoot(),
|
|
198
|
+
logger,
|
|
199
|
+
...(stack.credentialProvider ? { credentialProvider: stack.credentialProvider } : {}),
|
|
200
|
+
});
|
|
192
201
|
servers.push(egress);
|
|
193
202
|
logger.info("egress_ready", { url: egress.url });
|
|
194
203
|
}
|
|
@@ -220,6 +229,7 @@ export async function main() {
|
|
|
220
229
|
if (stack.tuning)
|
|
221
230
|
useHostTuning(stack.tuning.values, stack.tuning.overrides);
|
|
222
231
|
useWorkloadToken(stack.workloadToken ?? {});
|
|
232
|
+
useHostIdentity(stack.modelHostIdentity ?? []);
|
|
223
233
|
if (stack.services.has("gates") || stack.services.has("keys") || stack.services.has("egress"))
|
|
224
234
|
return runGates(stack);
|
|
225
235
|
if (stack.services.has("harness"))
|
|
@@ -269,6 +279,7 @@ export async function main() {
|
|
|
269
279
|
endpoints: describeEndpoints(stack.endpoints),
|
|
270
280
|
objectStore: stack.objectStore ? "s3" : "fs",
|
|
271
281
|
traces: stack.traces?.export ? "otlp" : "off",
|
|
282
|
+
...(stack.credentialProvider ? { credentialProvider: stack.credentialProvider.url } : {}),
|
|
272
283
|
});
|
|
273
284
|
const infra = createInfra(stack, { logger });
|
|
274
285
|
const database = infra.database;
|
|
@@ -375,6 +386,10 @@ export async function main() {
|
|
|
375
386
|
harnessImage: stack.sandboxes.harnessImage,
|
|
376
387
|
}
|
|
377
388
|
: undefined;
|
|
389
|
+
// The outside credential provider: each pod Agent segment's variables it names (CP-E).
|
|
390
|
+
const credentialProvider = stack.credentialProvider
|
|
391
|
+
? createCredentialProvider(stack.credentialProvider, { logger })
|
|
392
|
+
: undefined;
|
|
378
393
|
const tenantSettings = stack.tenant ?? { name: "default" };
|
|
379
394
|
const module = createTenantModule({
|
|
380
395
|
open: createPostgresTenantOpener({
|
|
@@ -402,6 +417,7 @@ export async function main() {
|
|
|
402
417
|
...(blobs ? { blobs } : {}),
|
|
403
418
|
...(stack.harnessMode === "remote" ? { harness: "remote" } : {}),
|
|
404
419
|
...(pods ? { pods } : {}),
|
|
420
|
+
...(credentialProvider ? { credentialProvider } : {}),
|
|
405
421
|
...(stack.traces
|
|
406
422
|
? { traces: { resource: stack.traces.resource, content: new Set(stack.traces.content) } }
|
|
407
423
|
: {}),
|
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
* their source (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`) and which content they carry
|
|
43
43
|
* (`NYLORUN_TRACE_CONTENT`, or all of it with `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`).
|
|
44
44
|
* So is the Host tuning (`NYLORUN_TUNING`, `tuning.ts`), for every service: its timings,
|
|
45
|
-
* retries and internal sizes.
|
|
45
|
+
* retries and internal sizes. So is the outside credential provider
|
|
46
|
+
* (`NYLORUN_CREDENTIAL_PROVIDER_URL`, `NYLORUN_CREDENTIAL_PROVIDER_TOKEN`), for egress-gate and
|
|
47
|
+
* a process running core or loop (`gates/credential-provider.ts`).
|
|
46
48
|
*/
|
|
47
49
|
import { type TraceResource } from "../traces/otlp.js";
|
|
48
50
|
import { type TraceContentKind } from "../traces/map.js";
|
|
@@ -188,6 +190,20 @@ export interface StackConfig {
|
|
|
188
190
|
file?: string;
|
|
189
191
|
origins?: string[];
|
|
190
192
|
};
|
|
193
|
+
/**
|
|
194
|
+
* `NYLORUN_MODEL_HOST_IDENTITY`: the comma-separated providers whose models may use the Host's
|
|
195
|
+
* own identity (ADR-71, `vault/host-identity.ts`). Absent: none.
|
|
196
|
+
*/
|
|
197
|
+
modelHostIdentity?: string[];
|
|
198
|
+
/**
|
|
199
|
+
* `NYLORUN_CREDENTIAL_PROVIDER_URL` and `NYLORUN_CREDENTIAL_PROVIDER_TOKEN`, for a process running
|
|
200
|
+
* egress, core or loop (Host feature `credential-provider`): the outside service egress-gate
|
|
201
|
+
* hands an Agent owner's connected hosts to, and core asks for their variables. Absent: none.
|
|
202
|
+
*/
|
|
203
|
+
credentialProvider?: {
|
|
204
|
+
url: string;
|
|
205
|
+
token: string;
|
|
206
|
+
};
|
|
191
207
|
/** Present when the process runs gates. */
|
|
192
208
|
gates?: GatesConfig;
|
|
193
209
|
/** Present when the process runs egress. */
|
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
* their source (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`) and which content they carry
|
|
43
43
|
* (`NYLORUN_TRACE_CONTENT`, or all of it with `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`).
|
|
44
44
|
* So is the Host tuning (`NYLORUN_TUNING`, `tuning.ts`), for every service: its timings,
|
|
45
|
-
* retries and internal sizes.
|
|
45
|
+
* retries and internal sizes. So is the outside credential provider
|
|
46
|
+
* (`NYLORUN_CREDENTIAL_PROVIDER_URL`, `NYLORUN_CREDENTIAL_PROVIDER_TOKEN`), for egress-gate and
|
|
47
|
+
* a process running core or loop (`gates/credential-provider.ts`).
|
|
46
48
|
*/
|
|
47
49
|
import { DEFAULT_SERVICE_NAME } from "../traces/otlp.js";
|
|
48
50
|
import { TRACE_CONTENT_KINDS } from "../traces/map.js";
|
|
@@ -50,6 +52,7 @@ import { isTenantId } from "@nylorun/core/compatibility";
|
|
|
50
52
|
import { LOOPBACK_HOSTS } from "@nylorun/core/contracts";
|
|
51
53
|
import { blockedAddresses } from "../sandbox/pods/network-gate.js";
|
|
52
54
|
import { HostTuningError, parseHostTuning } from "../tuning.js";
|
|
55
|
+
import { HOST_IDENTITY_PROVIDERS } from "../vault/host-identity.js";
|
|
53
56
|
export const RUNTIME_SERVICES = ["core", "loop", "gates", "keys", "egress", "harness"];
|
|
54
57
|
/**
|
|
55
58
|
* Services that may share a process (D12): they hold the same secrets and parse the same
|
|
@@ -327,6 +330,8 @@ export function parseStackConfig(env, argv) {
|
|
|
327
330
|
const delivery = parseDelivery(env);
|
|
328
331
|
const tenant = servesApi ? parseTenant(env) : undefined;
|
|
329
332
|
const workloadToken = parseWorkloadToken(env);
|
|
333
|
+
const modelHostIdentity = parseModelHostIdentity(env);
|
|
334
|
+
const credentialProvider = servesApi || services.has("egress") ? parseCredentialProvider(env) : undefined;
|
|
330
335
|
const objectStore = parseObjectStore(env);
|
|
331
336
|
const traces = services.has("core") ? parseTraces(env) : undefined;
|
|
332
337
|
const removedResolverVariables = Object.keys(env)
|
|
@@ -350,6 +355,8 @@ export function parseStackConfig(env, argv) {
|
|
|
350
355
|
...(delivery ? { delivery } : {}),
|
|
351
356
|
...(tenant ? { tenant } : {}),
|
|
352
357
|
...(workloadToken ? { workloadToken } : {}),
|
|
358
|
+
...(modelHostIdentity ? { modelHostIdentity } : {}),
|
|
359
|
+
...(credentialProvider ? { credentialProvider } : {}),
|
|
353
360
|
...(objectStore ? { objectStore } : {}),
|
|
354
361
|
...(traces ? { traces } : {}),
|
|
355
362
|
...(tuning ? { tuning } : {}),
|
|
@@ -468,6 +475,45 @@ function parseWorkloadToken(env) {
|
|
|
468
475
|
return undefined;
|
|
469
476
|
return { ...(file ? { file } : {}), ...(origins ? { origins } : {}) };
|
|
470
477
|
}
|
|
478
|
+
/** `StackConfig.modelHostIdentity` (ADR-71): each a provider that can use the Host's identity. */
|
|
479
|
+
function parseModelHostIdentity(env) {
|
|
480
|
+
const raw = read(env, "NYLORUN_MODEL_HOST_IDENTITY");
|
|
481
|
+
if (raw === undefined)
|
|
482
|
+
return undefined;
|
|
483
|
+
const providers = [...new Set(raw.split(",").map((item) => item.trim()).filter((item) => item !== ""))];
|
|
484
|
+
for (const provider of providers) {
|
|
485
|
+
if (!HOST_IDENTITY_PROVIDERS.includes(provider))
|
|
486
|
+
throw new StackConfigError(`NYLORUN_MODEL_HOST_IDENTITY: ${provider} can't use the Host's identity (only ${HOST_IDENTITY_PROVIDERS.join(", ")})`);
|
|
487
|
+
}
|
|
488
|
+
return providers.length ? providers : undefined;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* `StackConfig.credentialProvider`: the provider's `http` or `https` base URL (no credentials,
|
|
492
|
+
* query or fragment; `http` is for a provider on the Docker bridge or the machine itself) and its
|
|
493
|
+
* token, each required with the other.
|
|
494
|
+
*/
|
|
495
|
+
function parseCredentialProvider(env) {
|
|
496
|
+
const raw = read(env, "NYLORUN_CREDENTIAL_PROVIDER_URL");
|
|
497
|
+
const token = read(env, "NYLORUN_CREDENTIAL_PROVIDER_TOKEN");
|
|
498
|
+
if (raw === undefined && token === undefined)
|
|
499
|
+
return undefined;
|
|
500
|
+
if (raw === undefined)
|
|
501
|
+
throw new StackConfigError("NYLORUN_CREDENTIAL_PROVIDER_TOKEN is set without NYLORUN_CREDENTIAL_PROVIDER_URL: set the provider's base URL, such as http://172.17.0.1:18793/edge/provider");
|
|
502
|
+
if (token === undefined)
|
|
503
|
+
throw new StackConfigError("NYLORUN_CREDENTIAL_PROVIDER_TOKEN is required with NYLORUN_CREDENTIAL_PROVIDER_URL: the bearer the provider accepts");
|
|
504
|
+
let url;
|
|
505
|
+
try {
|
|
506
|
+
url = new URL(raw);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
throw new StackConfigError(`NYLORUN_CREDENTIAL_PROVIDER_URL is not a URL: ${raw}`);
|
|
510
|
+
}
|
|
511
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || url.search || url.hash)
|
|
512
|
+
throw new StackConfigError("NYLORUN_CREDENTIAL_PROVIDER_URL must be an http or https base URL with no credentials, query or fragment, such as http://172.17.0.1:18793/edge/provider");
|
|
513
|
+
if (token.length < 16 || !/^[\x21-\x7e]+$/.test(token))
|
|
514
|
+
throw new StackConfigError("NYLORUN_CREDENTIAL_PROVIDER_TOKEN must be at least 16 printable characters with no spaces");
|
|
515
|
+
return { url: `${url.origin}${url.pathname}`.replace(/\/+$/, ""), token };
|
|
516
|
+
}
|
|
471
517
|
/** `NYLORUN_WORKLOAD_TOKEN_ORIGINS`: each an `https` origin (`http` only to a loopback host). */
|
|
472
518
|
function parseOrigins(raw) {
|
|
473
519
|
if (raw === undefined)
|