@nylorun/runtime 0.28.0-beta → 0.30.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 (57) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +1 -1
  3. package/dist/api/components.d.ts +109 -2
  4. package/dist/api/http/define.d.ts +4 -0
  5. package/dist/api/http/define.js +20 -9
  6. package/dist/api/http/routes/agent-manifests.d.ts +1 -1
  7. package/dist/api/http/routes/agent-manifests.js +1 -1
  8. package/dist/api/http/routes/agents.js +6 -6
  9. package/dist/api/http/routes/reads.js +29 -4
  10. package/dist/api/http/routes/tenant.js +5 -2
  11. package/dist/api/http/routes/triggers.js +15 -12
  12. package/dist/api/openapi.js +3 -3
  13. package/dist/gates/egress-credentials.js +2 -13
  14. package/dist/gates/handler.js +2 -1
  15. package/dist/gates/http-tool.js +1 -1
  16. package/dist/gates/in-process.d.ts +9 -0
  17. package/dist/gates/in-process.js +21 -0
  18. package/dist/gates/meter.js +16 -0
  19. package/dist/gates/tenant-vaults.d.ts +1 -1
  20. package/dist/host/config-for.d.ts +2 -0
  21. package/dist/host/config-for.js +1 -0
  22. package/dist/host/main.js +3 -0
  23. package/dist/host/stack-config.d.ts +10 -0
  24. package/dist/host/stack-config.js +36 -0
  25. package/dist/management-openapi.json +492 -80
  26. package/dist/mcp/connect.d.ts +36 -7
  27. package/dist/mcp/connect.js +76 -15
  28. package/dist/mcp/gateway-outcomes.d.ts +14 -0
  29. package/dist/mcp/gateway-outcomes.js +46 -0
  30. package/dist/mcp/pool.js +5 -0
  31. package/dist/model/call-headers.d.ts +15 -0
  32. package/dist/model/call-headers.js +9 -0
  33. package/dist/model/models.d.ts +7 -0
  34. package/dist/model/models.js +3 -0
  35. package/dist/model/pi-model.d.ts +6 -0
  36. package/dist/model/pi-model.js +36 -3
  37. package/dist/openapi.json +331 -215
  38. package/dist/store/postgres/drizzle/0037_model_usage_cost_split.sql +4 -0
  39. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  40. package/dist/store/postgres/reads.js +31 -1
  41. package/dist/store/postgres/schema.d.ts +78 -5
  42. package/dist/store/postgres/schema.js +12 -3
  43. package/dist/tenant/agents.js +7 -3
  44. package/dist/tenant/transcript.d.ts +8 -0
  45. package/dist/tenant/transcript.js +2 -0
  46. package/dist/tenant/types.d.ts +5 -0
  47. package/dist/vault/headers.d.ts +8 -0
  48. package/dist/vault/headers.js +25 -0
  49. package/dist/vault/host-model.js +8 -5
  50. package/dist/vault/service.d.ts +12 -5
  51. package/dist/vault/service.js +43 -6
  52. package/dist/vault/sources.js +22 -1
  53. package/dist/vault/workload-token.d.ts +16 -0
  54. package/dist/vault/workload-token.js +58 -0
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/package.json +3 -3
@@ -307,7 +307,10 @@ export function tenantRoutes(api) {
307
307
  path: "/v1/tenant/tool-policy",
308
308
  tags: ["Tenant"],
309
309
  summary: "Get the Tenant's tool policy",
310
- responses: { 200: json(ToolPolicy, "The rules the Tool Gate applies to every MCP and HTTP tool call") },
310
+ responses: {
311
+ 200: json(ToolPolicy, "The rules the Tool Gate applies to every MCP and HTTP tool call"),
312
+ 409: { description: "A stored rule names an MCP tool by its model-facing `server__tool` name: every call is refused until the policy is set again" },
313
+ },
311
314
  }, async (c) => {
312
315
  try {
313
316
  return jsonResponse(200, await c.env.tenant.store.tx((t) => readToolPolicy(t)));
@@ -325,7 +328,7 @@ export function tenantRoutes(api) {
325
328
  path: "/v1/tenant/tool-policy",
326
329
  tags: ["Tenant"],
327
330
  summary: "Set the Tenant's tool policy",
328
- description: "Replaces every rule. The Tool Gate applies them to each MCP and HTTP tool call of every harness, before it sends the call: the strictest rule that matches decides. `deny` refuses the call (the model gets `tool.denied`), `ask` holds it and pauses the turn until a person answers it with an `approve` command on the session, `allow` runs it. A call no rule matches runs. A rule matches when every selector it sets matches: `tool` (the name the model calls it by; `*` matches any run of characters), `server` (an MCP server's name), `kind` (`mcp` or `http`), `agent` (the agent that calls it) and `when` (fields of the call's input by dotted path, each tested with `equals`, `in`, `gt`, `gte`, `lt`, `lte` or `matches`). Sandbox tools never cross the gate. An empty list allows every call.",
331
+ description: "Replaces every rule. The Tool Gate applies them to each MCP and HTTP tool call of every harness, before it sends the call: the strictest rule that matches decides. `deny` refuses the call (the model gets `tool.denied`), `ask` holds it and pauses the turn until a person answers it with an `approve` command on the session, `allow` runs it. A call no rule matches runs. A rule matches when every selector it sets matches: `tool` (the tool's identity: an MCP tool's own name on its `server`, or an HTTP tool's declared name, never the name the model calls it by; `*` matches any run of characters), `server` (an MCP server's name), `kind` (`mcp` or `http`), `agent` (the agent that calls it: the agent manifest's name, or the id of an agent used as a tool or of a flow's agent, never an Agent's id) and `when` (fields of the call's input by dotted path, each tested with `equals`, `in`, `gt`, `gte`, `lt`, `lte` or `matches`). A `tool` with `__` in it, a model-facing `server__tool` name, is refused (`400`): name an MCP tool by `server` and `tool`. A stored policy with such a rule refuses every call until it is set again. Sandbox tools never cross the gate. An empty list allows every call.",
329
332
  request: { body: body(PutToolPolicyRequest) },
330
333
  responses: { 200: json(ToolPolicy, "The policy now in force") },
331
334
  }, async (c) => {
@@ -44,7 +44,7 @@ export function triggerRoutes(api) {
44
44
  }, async (c) => {
45
45
  const id = idOf(c.req.param("agentId"));
46
46
  assertReaches(c.get("scope"), id);
47
- return jsonResponse(200, await listTriggers(c.env.tenant, id, originOf(c.req.url)));
47
+ return jsonResponse(200, await listTriggers(c.env.tenant, id, originOf(c)));
48
48
  });
49
49
  for (const action of ["pause", "resume"])
50
50
  tenantRoute(api, WRITE, {
@@ -63,7 +63,7 @@ export function triggerRoutes(api) {
63
63
  }, async (c) => {
64
64
  const id = idOf(c.req.param("agentId"));
65
65
  assertReaches(c.get("scope"), id);
66
- return jsonResponse(200, await setTriggerStatus(c.env.tenant, id, c.req.param("triggerId"), action, originOf(c.req.url)));
66
+ return jsonResponse(200, await setTriggerStatus(c.env.tenant, id, c.req.param("triggerId"), action, originOf(c)));
67
67
  });
68
68
  tenantRoute(api, WRITE, {
69
69
  method: "post",
@@ -111,7 +111,7 @@ export function triggerRoutes(api) {
111
111
  }
112
112
  /** A delivery: no credential, no protocol header, any body; the endpoint id and the signature are the grant. */
113
113
  const DELIVERY = {
114
- credentials: ["application", "subject", "token"],
114
+ credentials: [],
115
115
  scopes: "any",
116
116
  anonymous: true,
117
117
  unversioned: true,
@@ -132,20 +132,20 @@ export function webhookRoutes(api) {
132
132
  }, async (c) => {
133
133
  const id = idOf(c.req.param("agentId"));
134
134
  assertReaches(c.get("scope"), id);
135
- return jsonResponse(200, await rotateWebhookSecret(c.env.tenant, id, c.req.param("triggerId"), originOf(c.req.url)));
135
+ return jsonResponse(200, await rotateWebhookSecret(c.env.tenant, id, c.req.param("triggerId"), originOf(c)));
136
136
  });
137
137
  tenantRoute(api, DELIVERY, {
138
138
  method: "post",
139
139
  path: "/v1/webhooks/{endpointId}",
140
140
  tags: ["Triggers"],
141
141
  summary: "Deliver to a webhook trigger",
142
- description: "A sender's delivery, signed with Standard Webhooks (`webhook-id`, `webhook-timestamp` within 5 minutes by default, `webhook-signature`): no credential and no `Nylorun-Protocol`. The body, any media type, at most the Runtime limit `webhookBodyBytes`, is read as bytes and verified before anything else. It runs the trigger once per `webhook-id`: a retry answers the first run. The body reaches the model as data under the trigger's prompt. Other senders (GitHub, Stripe, Slack) are relayed by an app.",
142
+ description: "A sender's delivery, signed with Standard Webhooks (`webhook-id`, `webhook-timestamp` within 5 minutes by default, `webhook-signature`): no credential (an `Authorization` header is ignored) and no `Nylorun-Protocol`. The body, any media type, at most the Runtime limit `webhookBodyBytes`, is read as bytes and verified before anything else. It runs the trigger once per `webhook-id`: a retry answers the first run. The body reaches the model as data under the trigger's prompt. Other senders (GitHub, Stripe, Slack) are relayed by an app.",
143
143
  request: {
144
144
  params: z.object({ endpointId: z.string().meta({ description: "The trigger's endpoint id, `wh_…`" }) }),
145
145
  headers: z.object({
146
- "webhook-id": z.string().optional().meta({ description: "The delivery's id; a retry sends the same" }),
147
- "webhook-timestamp": z.string().optional().meta({ description: "Seconds since the epoch" }),
148
- "webhook-signature": z.string().optional().meta({ description: "`v1,<base64>`, space-separated" }),
146
+ "webhook-id": z.string().meta({ description: "The delivery's id; a retry sends the same" }),
147
+ "webhook-timestamp": z.string().meta({ description: "Seconds since the epoch" }),
148
+ "webhook-signature": z.string().meta({ description: "`v1,<base64>`, space-separated" }),
149
149
  }),
150
150
  body: {
151
151
  required: true,
@@ -155,7 +155,7 @@ export function webhookRoutes(api) {
155
155
  },
156
156
  responses: {
157
157
  202: json(WebhookAcceptedResponse, "The run it made: started, queued, skipped or failed"),
158
- 401: { description: "Not signed with the endpoint's secret, or a timestamp out of tolerance" },
158
+ 401: { description: "Unsigned, not signed with the endpoint's secret, or a timestamp out of tolerance" },
159
159
  404: { description: "No endpoint has this id" },
160
160
  409: { description: "The trigger is paused (`trigger_paused`)" },
161
161
  413: { description: "Over `webhookBodyBytes`" },
@@ -174,7 +174,10 @@ export function webhookRoutes(api) {
174
174
  }));
175
175
  });
176
176
  }
177
- /** The origin a request reached: where its webhook URLs live. */
178
- function originOf(url) {
179
- return new URL(url).origin;
177
+ /**
178
+ * Where its webhook URLs live: the Runtime's public URL (`NYLORUN_PUBLIC_URL`), else the origin
179
+ * the request reached, which behind a proxy that rewrites `Host` is the Runtime's own.
180
+ */
181
+ function originOf(c) {
182
+ return c.env.tenant.config.publicUrl ?? new URL(c.req.url).origin;
180
183
  }
@@ -112,7 +112,7 @@ const RUNTIME_TAGS = [
112
112
  {
113
113
  name: "Agent manifests",
114
114
  group: "Agents",
115
- description: "Named, versioned agent manifests (protocol 16): one document with what runs inside an agent's loop (`harness`) and what the Runtime provides around it (`runtime`), checked against each other when it is saved. Save one with a plan first (`?dryRun=true`) and `If-Match`; an Agent is made from one version and keeps it, so a save changes no Agent. `client.agentManifests` in `@nylorun/agents`, and `nylorun apply`.",
115
+ description: "Named, versioned agent manifests (protocol 16): one document with what runs inside an agent's loop (`harness`) and what the Runtime provides around it (`runtime`), checked against each other when it is saved. Save one with a plan first (`?dryRun=true`) and `If-Match`; an Agent runs one version at a time, and a save moves every Agent that tracks the manifest to the new version, while a pinned one stays. `client.agentManifests` in `@nylorun/agents`, and `nylorun apply`.",
116
116
  operations: [
117
117
  "PUT /v1/agent-manifests/{name}",
118
118
  "GET /v1/agent-manifests",
@@ -155,7 +155,7 @@ const RUNTIME_TAGS = [
155
155
  {
156
156
  name: "Triggers",
157
157
  group: "Agents",
158
- description: "What starts an Agent's turns with no client connected (track T4, D74): the schedules, one-shot times and webhook endpoints its manifest's `runtime.triggers` declare. Each trigger's status is the Agent's: pause and resume it, run it now, make a webhook trigger's signing secret, and read its runs (`started`, `queued`, `skipped` or `failed`, with a reason). In `@nylorun/agents` and `nylorun agents triggers`.",
158
+ description: "What starts an Agent's turns with no client connected: the schedules, one-shot times and webhook endpoints its manifest's `runtime.triggers` declare. Each trigger's status is the Agent's: pause and resume it, run it now, make a webhook trigger's signing secret, and read its runs (`started`, `queued`, `skipped` or `failed`, with a reason). In `@nylorun/agents` and `nylorun agents triggers`.",
159
159
  operations: [
160
160
  "GET /v1/agents/{agentId}/triggers",
161
161
  "POST /v1/agents/{agentId}/triggers/{triggerId}/pause",
@@ -169,7 +169,7 @@ const RUNTIME_TAGS = [
169
169
  {
170
170
  name: "Memory",
171
171
  group: "Agents",
172
- description: "An Agent's memories (track T5, D72): Markdown files under `/memories` it keeps across sessions, wakes and upgrades, shaped like Anthropic's memory stores. List and read them, write one whole (with a `content_sha256` precondition), delete one or a folder, and read the version every change leaves, with who made it. In `@nylorun/agents` and `nylorun agents memories`.",
172
+ description: "An Agent's memories: Markdown files under `/memories` it keeps across sessions, wakes and upgrades, shaped like Anthropic's memory stores. List and read them, write one whole (with a `content_sha256` precondition), delete one or a folder, and read the version every change leaves, with who made it. In `@nylorun/agents` and `nylorun agents memories`.",
173
173
  operations: [
174
174
  "GET /v1/agents/{agentId}/memories",
175
175
  "GET /v1/agents/{agentId}/memories/{path}",
@@ -19,26 +19,15 @@ import { createServer } from "node:http";
19
19
  import { Agent, request } from "node:https";
20
20
  import { createSecureContext, TLSSocket } from "node:tls";
21
21
  import { hostTuning } from "../tuning.js";
22
+ import { HOP_BY_HOP_HEADERS } from "../vault/headers.js";
22
23
  /** The answer to a CONNECT the gate serves; a tunnel's byte counts leave it out. */
23
24
  export const CONNECT_ESTABLISHED = "HTTP/1.1 200 Connection Established\r\n\r\n";
24
- /** Headers that describe one hop and are never forwarded. */
25
- const HOP_BY_HOP = new Set([
26
- "connection",
27
- "keep-alive",
28
- "proxy-authenticate",
29
- "proxy-authorization",
30
- "proxy-connection",
31
- "te",
32
- "trailer",
33
- "transfer-encoding",
34
- "upgrade",
35
- ]);
36
25
  /** `rawHeaders` without hop-by-hop headers and `drop`, as lowercase names to values. */
37
26
  function forwardable(raw, drop) {
38
27
  const headers = {};
39
28
  for (let i = 0; i + 1 < raw.length; i += 2) {
40
29
  const name = raw[i].toLowerCase();
41
- if (HOP_BY_HOP.has(name) || name === drop)
30
+ if (HOP_BY_HOP_HEADERS.has(name) || name === drop)
42
31
  continue;
43
32
  (headers[name] ??= []).push(raw[i + 1]);
44
33
  }
@@ -6,7 +6,7 @@
6
6
  * the Tenant's ledger. Logs one line per call, never the prompt, the output or a credential.
7
7
  */
8
8
  import { classifyThrown } from "../model/classify.js";
9
- import { callFiles, inProcessModelGate, modelCallSettings, serve } from "./in-process.js";
9
+ import { callCaller, callFiles, inProcessModelGate, modelCallSettings, serve } from "./in-process.js";
10
10
  import { createMeter } from "./meter.js";
11
11
  import { GateRefusal } from "./tenant-vaults.js";
12
12
  export function createModelCallHandler(options) {
@@ -28,6 +28,7 @@ export function createModelCallHandler(options) {
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) } : {}),
31
+ caller: callCaller(vault.store),
31
32
  });
32
33
  outcome = await meter.call(vault.store, call, () => serve(vault.store, gate, call, signal));
33
34
  }
@@ -198,7 +198,7 @@ export async function runHttpTool(tenant, call, options) {
198
198
  if (authorized.status === "refused")
199
199
  return failed("http.credential", `The credential '${http.credential}' was refused: ${authorized.reason}`);
200
200
  if (authorized.status === "unauthenticated")
201
- return failed("http.credential", `The session's Environment holds no credential '${http.credential}' for ${authorized.url}`);
201
+ return failed("http.credential", `The session's Agent holds no credential '${http.credential}' for ${authorized.url}`);
202
202
  credential = authorized.headers;
203
203
  identity = authorized.identity;
204
204
  // A credential with `via` sends the call there, a gateway (R2b C2, Q9): to its one URL, so
@@ -11,6 +11,7 @@ import type { HostModelSecret } from "../vault/service.js";
11
11
  import type { ModelGate, ModelGateRequest } from "./model-gate.js";
12
12
  import { type FileResolver } from "../artifacts/files.js";
13
13
  import type { BlobStore } from "../blob/index.js";
14
+ import type { ModelCaller } from "../model/call-headers.js";
14
15
  export interface InProcessModelGateOptions {
15
16
  /** The Tenant's home: `piModel` redacts secrets it finds there from failure messages. */
16
17
  readonly root?: string;
@@ -20,6 +21,8 @@ export interface InProcessModelGateOptions {
20
21
  readonly settings?: ModelCallSettings;
21
22
  /** Reads the files a call's prompt names (protocol 6): artifacts, for model-gate. */
22
23
  readonly files?: (request: ModelGateRequest) => FileResolver;
24
+ /** Reads who makes a call, for a custom endpoint's per-call headers (#493). */
25
+ readonly caller?: (request: ModelGateRequest) => Promise<ModelCaller | undefined>;
23
26
  }
24
27
  /**
25
28
  * The files a call names, from the Tenant's artifacts, limited to the call's session: in
@@ -27,6 +30,12 @@ export interface InProcessModelGateOptions {
27
30
  * gate's route puts on the request (F5). A request without a session reads no file at all.
28
31
  */
29
32
  export declare function callFiles(store: SessionStore, blobs: BlobStore): (request: Pick<ModelGateRequest, "sessionId">) => FileResolver;
33
+ /**
34
+ * Who makes a call, from the Tenant's store: the Agent its session runs on, with its labels, and
35
+ * the session's owner when it is a person. The session is the loop's own in process, and the run
36
+ * token's over HTTP; the request's `agentId` names the manifest's agent, not the Agent.
37
+ */
38
+ export declare function callCaller(store: SessionStore): (request: Pick<ModelGateRequest, "sessionId">) => Promise<ModelCaller | undefined>;
30
39
  /**
31
40
  * The in-process gate of an open Tenant: reads its host model from its own vault and records
32
41
  * each call in its usage ledger. Only a Runtime without the gates service (embedding, the
@@ -5,6 +5,7 @@ import { createMeter } from "./meter.js";
5
5
  import { artifactFiles } from "../artifacts/files.js";
6
6
  import { usesFixtureModel } from "../tenant/model-setting.js";
7
7
  import { fixtureAnswer } from "./fixture-model.js";
8
+ import { isSubject } from "@nylorun/core/contracts";
8
9
  /**
9
10
  * The files a call names, from the Tenant's artifacts, limited to the call's session: in
10
11
  * process, the loop's own request; over HTTP, the session of the run token's claims, which the
@@ -13,6 +14,24 @@ import { fixtureAnswer } from "./fixture-model.js";
13
14
  export function callFiles(store, blobs) {
14
15
  return (request) => artifactFiles({ store, blobs, sessionId: request.sessionId });
15
16
  }
17
+ /**
18
+ * Who makes a call, from the Tenant's store: the Agent its session runs on, with its labels, and
19
+ * the session's owner when it is a person. The session is the loop's own in process, and the run
20
+ * token's over HTTP; the request's `agentId` names the manifest's agent, not the Agent.
21
+ */
22
+ export function callCaller(store) {
23
+ return (request) => store.tx(async (t) => {
24
+ const session = await t.get("sessions", request.sessionId);
25
+ const agent = session && (await t.environment(session.agentId));
26
+ if (!session || !agent)
27
+ return undefined;
28
+ return {
29
+ agentId: agent.id,
30
+ labels: agent.labels,
31
+ ...(isSubject(session.ownerUserId) ? { owner: session.ownerUserId } : {}),
32
+ };
33
+ });
34
+ }
16
35
  /**
17
36
  * The in-process gate of an open Tenant: reads its host model from its own vault and records
18
37
  * each call in its usage ledger. Only a Runtime without the gates service (embedding, the
@@ -30,6 +49,7 @@ export function tenantModelGate(options) {
30
49
  writeHostCredential: (credential) => vault.updateHostCredential(credential),
31
50
  settings: await modelCallSettings(options.store),
32
51
  ...(options.blobs ? { files: callFiles(options.store, options.blobs) } : {}),
52
+ caller: callCaller(options.store),
33
53
  });
34
54
  return serve(options.store, gate, request, signal);
35
55
  }),
@@ -58,6 +78,7 @@ export function inProcessModelGate(options) {
58
78
  writeHostCredential: options.writeHostCredential,
59
79
  ...(options.settings ? { settings: options.settings } : {}),
60
80
  ...(options.files ? { files: options.files(request) } : {}),
81
+ ...(options.caller ? { caller: () => options.caller(request) } : {}),
61
82
  });
62
83
  return adapter(request.call, {
63
84
  // The Runtime never journals the provider request (harness `durable.ts`), and
@@ -142,6 +142,22 @@ export function usageRow(request, outcome, at, timing) {
142
142
  ...(typeof firstTokenMs === "number" && Number.isFinite(firstTokenMs) && firstTokenMs >= 0
143
143
  ? { firstTokenMs: Math.round(firstTokenMs) }
144
144
  : {}),
145
+ ...costSplitColumns(outcome.evidence?.extras?.costSplit),
146
+ };
147
+ }
148
+ /** The ledger's cost-by-kind columns from the adapter's `costSplit`; none unless all four are costs. */
149
+ function costSplitColumns(split) {
150
+ if (!split || typeof split !== "object" || Array.isArray(split))
151
+ return {};
152
+ const { input, cacheRead, cacheWrite, output } = split;
153
+ const parts = [input, cacheRead, cacheWrite, output];
154
+ if (!parts.every((part) => typeof part === "number" && Number.isFinite(part) && part >= 0))
155
+ return {};
156
+ return {
157
+ costInputUsd: input,
158
+ costCacheReadUsd: cacheRead,
159
+ costCacheWriteUsd: cacheWrite,
160
+ costOutputUsd: output,
145
161
  };
146
162
  }
147
163
  /** The ledger rows a budget counts for `request`; undefined when it doesn't apply. */
@@ -29,7 +29,7 @@ export interface TenantVault {
29
29
  session(sessionId: string): Promise<Session | undefined>;
30
30
  /**
31
31
  * The authorization of one request to a session's remote MCP server or HTTP tool: its
32
- * credential from the vault of the session's Environment.
32
+ * credential from the vault of the session's Agent.
33
33
  */
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). */
@@ -14,4 +14,6 @@ export declare function configForFactory(options: {
14
14
  delivery?: TenantConfig["delivery"];
15
15
  /** The trusted issuers of the identity file (`NYLORUN_IDENTITY_FILE`). */
16
16
  issuers?: TenantConfig["issuers"];
17
+ /** The URL clients reach the Runtime API at (`NYLORUN_PUBLIC_URL`). */
18
+ publicUrl?: string;
17
19
  }): (id: string) => TenantConfig;
@@ -16,6 +16,7 @@ export function configForFactory(options) {
16
16
  model: options.model ?? { kind: "vault" },
17
17
  ...(options.delivery ? { delivery: options.delivery } : {}),
18
18
  ...(options.issuers ? { issuers: options.issuers } : {}),
19
+ ...(options.publicUrl ? { publicUrl: options.publicUrl } : {}),
19
20
  logger: {
20
21
  info: (message, fields) => options.logger.info(message, { tenantId: id, ...fields }),
21
22
  warn: (message, fields) => options.logger.warn(message, { tenantId: id, ...fields }),
package/dist/host/main.js CHANGED
@@ -65,6 +65,7 @@ import { startHarnessListener } from "../harness-api/ws-server.js";
65
65
  import { httpSandboxesClient } from "../sandbox/pods/client.js";
66
66
  import { retry } from "../retry.js";
67
67
  import { hostTuning, useHostTuning } from "../tuning.js";
68
+ import { useWorkloadToken } from "../vault/workload-token.js";
68
69
  const nodeRequire = createRequire(import.meta.url);
69
70
  function coreVersion() {
70
71
  try {
@@ -218,6 +219,7 @@ export async function main() {
218
219
  // Read once, before any service starts: each reads the parts it uses where it uses them.
219
220
  if (stack.tuning)
220
221
  useHostTuning(stack.tuning.values, stack.tuning.overrides);
222
+ useWorkloadToken(stack.workloadToken ?? {});
221
223
  if (stack.services.has("gates") || stack.services.has("keys") || stack.services.has("egress"))
222
224
  return runGates(stack);
223
225
  if (stack.services.has("harness"))
@@ -279,6 +281,7 @@ export async function main() {
279
281
  logger,
280
282
  ...(stack.delivery ? { delivery: stack.delivery } : {}),
281
283
  ...(issuers ? { issuers } : {}),
284
+ ...(stack.publicUrl ? { publicUrl: stack.publicUrl } : {}),
282
285
  });
283
286
  // The process's Durable Session Execution: Restate when its endpoints are set, else the
284
287
  // in-process memory execution. An invocation that arrives while the Tenant opens waits for it.
@@ -178,6 +178,16 @@ export interface HarnessServiceConfig {
178
178
  export interface StackConfig {
179
179
  /** The Runtime services this process runs. */
180
180
  services: RuntimeServices;
181
+ /**
182
+ * `NYLORUN_WORKLOAD_TOKEN_FILE`, the file of the Runtime's workload token, which a platform
183
+ * rotates and the Runtime reads at each use, and `NYLORUN_WORKLOAD_TOKEN_ORIGINS`, the
184
+ * comma-separated origins a `workload` credential's `via` may send it to (#496,
185
+ * `vault/workload-token.ts`). Absent: no workload token.
186
+ */
187
+ workloadToken?: {
188
+ file?: string;
189
+ origins?: string[];
190
+ };
181
191
  /** Present when the process runs gates. */
182
192
  gates?: GatesConfig;
183
193
  /** Present when the process runs egress. */
@@ -47,6 +47,7 @@
47
47
  import { DEFAULT_SERVICE_NAME } from "../traces/otlp.js";
48
48
  import { TRACE_CONTENT_KINDS } from "../traces/map.js";
49
49
  import { isTenantId } from "@nylorun/core/compatibility";
50
+ import { LOOPBACK_HOSTS } from "@nylorun/core/contracts";
50
51
  import { blockedAddresses } from "../sandbox/pods/network-gate.js";
51
52
  import { HostTuningError, parseHostTuning } from "../tuning.js";
52
53
  export const RUNTIME_SERVICES = ["core", "loop", "gates", "keys", "egress", "harness"];
@@ -325,6 +326,7 @@ export function parseStackConfig(env, argv) {
325
326
  throw new StackConfigError("NYLORUN_HARNESS_LISTEN_PORT must differ from NYLORUN_LISTEN_PORT");
326
327
  const delivery = parseDelivery(env);
327
328
  const tenant = servesApi ? parseTenant(env) : undefined;
329
+ const workloadToken = parseWorkloadToken(env);
328
330
  const objectStore = parseObjectStore(env);
329
331
  const traces = services.has("core") ? parseTraces(env) : undefined;
330
332
  const removedResolverVariables = Object.keys(env)
@@ -347,6 +349,7 @@ export function parseStackConfig(env, argv) {
347
349
  ...(sandboxes ? { sandboxes } : {}),
348
350
  ...(delivery ? { delivery } : {}),
349
351
  ...(tenant ? { tenant } : {}),
352
+ ...(workloadToken ? { workloadToken } : {}),
350
353
  ...(objectStore ? { objectStore } : {}),
351
354
  ...(traces ? { traces } : {}),
352
355
  ...(tuning ? { tuning } : {}),
@@ -454,6 +457,39 @@ function parseTenant(env) {
454
457
  ...(managementKeyFile ? { managementKeyFile } : {}),
455
458
  };
456
459
  }
460
+ /**
461
+ * `StackConfig.workloadToken` (#496): the Runtime's workload token, which the gates read for a
462
+ * custom model and for `workload` credentials, and the in-process Tenant does without them.
463
+ */
464
+ function parseWorkloadToken(env) {
465
+ const file = read(env, "NYLORUN_WORKLOAD_TOKEN_FILE");
466
+ const origins = parseOrigins(read(env, "NYLORUN_WORKLOAD_TOKEN_ORIGINS"));
467
+ if (file === undefined && origins === undefined)
468
+ return undefined;
469
+ return { ...(file ? { file } : {}), ...(origins ? { origins } : {}) };
470
+ }
471
+ /** `NYLORUN_WORKLOAD_TOKEN_ORIGINS`: each an `https` origin (`http` only to a loopback host). */
472
+ function parseOrigins(raw) {
473
+ if (raw === undefined)
474
+ return undefined;
475
+ return raw
476
+ .split(",")
477
+ .map((item) => item.trim())
478
+ .filter((item) => item !== "")
479
+ .map((item) => {
480
+ let url;
481
+ try {
482
+ url = new URL(item);
483
+ }
484
+ catch {
485
+ throw new StackConfigError(`NYLORUN_WORKLOAD_TOKEN_ORIGINS: ${item} is not a URL`);
486
+ }
487
+ const loopback = url.protocol === "http:" && LOOPBACK_HOSTS.test(url.hostname);
488
+ if ((url.protocol !== "https:" && !loopback) || url.origin !== item.replace(/\/$/, ""))
489
+ throw new StackConfigError(`NYLORUN_WORKLOAD_TOKEN_ORIGINS: ${item} must be an https origin, such as https://gateway.example.com`);
490
+ return url.origin;
491
+ });
492
+ }
457
493
  const GATES_TOKEN = /^[0-9a-f]{64,}$/i;
458
494
  /** `NYLORUN_HARNESS`: `in-process` (the default when unset) or `remote`. */
459
495
  function parseHarnessMode(env) {