@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/api/ag-ui/routes.js +4 -2
  3. package/dist/api/ag-ui/translate.js +3 -1
  4. package/dist/api/components.d.ts +35 -0
  5. package/dist/api/components.js +3 -1
  6. package/dist/api/http/routes/sessions.js +63 -2
  7. package/dist/api/openapi.js +3 -0
  8. package/dist/gates/credential-provider.d.ts +42 -0
  9. package/dist/gates/credential-provider.js +140 -0
  10. package/dist/gates/egress-credentials.d.ts +12 -0
  11. package/dist/gates/egress-credentials.js +60 -6
  12. package/dist/gates/egress.d.ts +14 -1
  13. package/dist/gates/egress.js +67 -19
  14. package/dist/gates/handler.js +2 -2
  15. package/dist/gates/in-process.d.ts +4 -3
  16. package/dist/gates/in-process.js +11 -5
  17. package/dist/gates/tenant-vaults.d.ts +4 -3
  18. package/dist/gates/tenant-vaults.js +2 -2
  19. package/dist/harness-api/start.js +30 -3
  20. package/dist/host/gates.d.ts +8 -1
  21. package/dist/host/gates.js +6 -1
  22. package/dist/host/main.js +17 -1
  23. package/dist/host/stack-config.d.ts +17 -1
  24. package/dist/host/stack-config.js +47 -1
  25. package/dist/management-openapi.json +61 -5
  26. package/dist/model/descriptor.d.ts +5 -4
  27. package/dist/model/descriptor.js +4 -3
  28. package/dist/model/pi-model.js +17 -1
  29. package/dist/openapi.json +516 -2
  30. package/dist/tenant/context.d.ts +12 -1
  31. package/dist/tenant/runtime.d.ts +6 -0
  32. package/dist/tenant/runtime.js +1 -0
  33. package/dist/tenant/session-model.d.ts +16 -0
  34. package/dist/tenant/session-model.js +46 -0
  35. package/dist/tuning.d.ts +6 -0
  36. package/dist/tuning.js +14 -0
  37. package/dist/vault/host-identity.d.ts +24 -0
  38. package/dist/vault/host-identity.js +35 -0
  39. package/dist/vault/host-model.d.ts +9 -5
  40. package/dist/vault/host-model.js +30 -19
  41. package/dist/vault/service.d.ts +18 -2
  42. package/dist/vault/service.js +52 -9
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +6 -6
@@ -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
  /**
@@ -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`, else 403;
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 one exception (R2c, D50): port 443 of a host that an
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. Every other host gets the tunnel. Any other request
24
- * (an absolute-form plain HTTP request included) is answered 405. Refusals are logged; the token
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({ credentials: options.credentials, logger, idleMs })
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
- if (!egressAllows(host, sandbox.allow))
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
- const sandbox = await store.tx((t) => t.environment(sandboxId));
400
- const pod = sandbox?.pod;
401
- if (!sandbox || sandbox.kind !== "pod" || !pod)
402
- return undefined;
403
- if (pod.desired === "deleted" || pod.observed === "lost")
404
- return undefined;
405
- return {
406
- epoch: pod.hostEpoch,
407
- ...(pod.podUid ? { podUid: pod.podUid } : {}),
408
- // The applied allowlist: a recipe change writes it at once (Q8).
409
- allow: sandbox.applied.network?.allowedHosts ?? [],
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
  }
@@ -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
- readonly readHostModel: () => Promise<HostModelSecret | undefined>;
19
- /** Writes back a credential pi-ai refreshed (OAuth). */
20
- readonly writeHostCredential: (credential: Credential) => Promise<void>;
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;
@@ -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: options.readHostModel,
78
- writeHostCredential: options.writeHostCredential,
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
- readHostModel(): Promise<HostModelSecret | undefined>;
26
- /** Writes back a credential pi-ai refreshed (OAuth). */
27
- writeHostCredential(credential: Credential): Promise<void>;
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 started = model ? { ...opened, model } : opened;
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;
@@ -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>;
@@ -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({ egress: stack.egress, database, hostRoot: resolveHostRoot(), logger });
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)