@nylorun/runtime 0.29.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.
@@ -186,6 +186,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
186
186
  code: string;
187
187
  message: string;
188
188
  server?: string | undefined;
189
+ gateway?: string | undefined;
189
190
  retryable?: boolean | undefined;
190
191
  };
191
192
  output?: undefined;
@@ -208,6 +209,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
208
209
  code: string;
209
210
  message: string;
210
211
  server?: string | undefined;
212
+ gateway?: string | undefined;
211
213
  retryable?: boolean | undefined;
212
214
  };
213
215
  output?: undefined;
@@ -230,6 +232,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
230
232
  code: string;
231
233
  message: string;
232
234
  server?: string | undefined;
235
+ gateway?: string | undefined;
233
236
  retryable?: boolean | undefined;
234
237
  };
235
238
  output?: undefined;
@@ -252,6 +255,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
252
255
  code: string;
253
256
  message: string;
254
257
  server?: string | undefined;
258
+ gateway?: string | undefined;
255
259
  retryable?: boolean | undefined;
256
260
  };
257
261
  output?: undefined;
@@ -344,6 +348,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
344
348
  code: string;
345
349
  message: string;
346
350
  server?: string | undefined;
351
+ gateway?: string | undefined;
347
352
  retryable?: boolean | undefined;
348
353
  };
349
354
  output?: undefined;
@@ -368,6 +373,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
368
373
  code: string;
369
374
  message: string;
370
375
  server?: string | undefined;
376
+ gateway?: string | undefined;
371
377
  retryable?: boolean | undefined;
372
378
  };
373
379
  output?: undefined;
@@ -392,6 +398,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
392
398
  code: string;
393
399
  message: string;
394
400
  server?: string | undefined;
401
+ gateway?: string | undefined;
395
402
  retryable?: boolean | undefined;
396
403
  };
397
404
  output?: undefined;
@@ -416,6 +423,7 @@ export declare function toolCompleted(request: HostEffect, value: unknown, facts
416
423
  code: string;
417
424
  message: string;
418
425
  server?: string | undefined;
426
+ gateway?: string | undefined;
419
427
  retryable?: boolean | undefined;
420
428
  };
421
429
  output?: undefined;
@@ -151,6 +151,8 @@ export function toolCompleted(request, value, facts) {
151
151
  message: String(outcome.message ?? ""),
152
152
  // `credential_rejected` (R2b C1): which server.
153
153
  ...(typeof outcome.server === "string" ? { server: outcome.server } : {}),
154
+ // And the gateway that refused the Runtime's workload token (#496).
155
+ ...(typeof outcome.gateway === "string" ? { gateway: outcome.gateway } : {}),
154
156
  ...(typeof outcome.retryable === "boolean" ? { retryable: outcome.retryable } : {}),
155
157
  },
156
158
  }
@@ -1,3 +1,11 @@
1
+ /** Headers that describe one hop and are never forwarded or set by a credential or a setting. */
2
+ export declare const HOP_BY_HOP_HEADERS: ReadonlySet<string>;
3
+ /**
4
+ * Why a custom model endpoint's header (`settings.headers`, `settings.callHeaders`, #493) may not
5
+ * be `name`, or undefined when it may: the API key's `authorization`, a hop-by-hop header, a
6
+ * transport header or `Nylorun-*`.
7
+ */
8
+ export declare function modelHeaderError(name: string): string | undefined;
1
9
  /**
2
10
  * The secret values of a credential's `headers`, as a server could echo them (R2b C8, Q16): each
3
11
  * value of at least 8 characters, and the token of a `Bearer …` value. The identity header names
@@ -18,6 +18,31 @@ const RESERVED_HEADERS = new Set([
18
18
  "last-event-id",
19
19
  "idempotency-key",
20
20
  ]);
21
+ /** Headers that describe one hop and are never forwarded or set by a credential or a setting. */
22
+ export const HOP_BY_HOP_HEADERS = new Set([
23
+ "connection",
24
+ "keep-alive",
25
+ "proxy-authenticate",
26
+ "proxy-authorization",
27
+ "proxy-connection",
28
+ "te",
29
+ "trailer",
30
+ "transfer-encoding",
31
+ "upgrade",
32
+ ]);
33
+ /**
34
+ * Why a custom model endpoint's header (`settings.headers`, `settings.callHeaders`, #493) may not
35
+ * be `name`, or undefined when it may: the API key's `authorization`, a hop-by-hop header, a
36
+ * transport header or `Nylorun-*`.
37
+ */
38
+ export function modelHeaderError(name) {
39
+ const lower = name.toLowerCase();
40
+ if (lower === "authorization")
41
+ return "A model header may not set authorization: the model's auth sends it";
42
+ if (HOP_BY_HOP_HEADERS.has(lower) || isReservedHeader(lower))
43
+ return `A model header may not set ${lower}`;
44
+ return undefined;
45
+ }
21
46
  /**
22
47
  * The secret values of a credential's `headers`, as a server could echo them (R2b C8, Q16): each
23
48
  * value of at least 8 characters, and the token of a `Bearer …` value. The identity header names
@@ -24,11 +24,14 @@ export class HostModelVault {
24
24
  const payload = readHostPayload(this.kek(), row, binding);
25
25
  const credential = binding.authType === "oauth"
26
26
  ? { type: "oauth", ...payload.oauth }
27
- : {
28
- type: "api_key",
29
- key: payload.apiKey,
30
- ...(payload.env ? { env: payload.env } : {}),
31
- };
27
+ : binding.authType === "workload"
28
+ ? // The workload token is read from its file at each call (#496), never stored.
29
+ { type: "workload" }
30
+ : {
31
+ type: "api_key",
32
+ key: payload.apiKey,
33
+ ...(payload.env ? { env: payload.env } : {}),
34
+ };
32
35
  return {
33
36
  provider: binding.provider,
34
37
  model: binding.model,
@@ -1,4 +1,4 @@
1
- import type { CredentialCoverage, CredentialCoverageRequest, CredentialIdentity, EnvironmentSecretSlot, AgentSecretView, HostModelProviderInfo, HostModelView, PutAgentSecretRequest, PutHostModelRequest, CustomModelSettings, ModelInput, SelectHostModelRequest } from "@nylorun/core/contracts";
1
+ import type { CredentialCoverage, CredentialCoverageRequest, CredentialIdentity, EnvironmentSecretSlot, AgentSecretView, HostModelAuthType, HostModelProviderInfo, HostModelView, PutAgentSecretRequest, PutHostModelRequest, CustomModelSettings, ModelInput, SelectHostModelRequest } from "@nylorun/core/contracts";
2
2
  import type { AgentManifest, WorkflowManifest } from "@nylorun/core/define";
3
3
  import type { SessionStore, Tx, VaultCredentialRow } from "../store/types.js";
4
4
  export declare const HOST_VAULT_ID = "host";
@@ -10,9 +10,9 @@ export type HostModelSecret = {
10
10
  settings?: CustomModelSettings;
11
11
  /** The inputs the operator added to catalog models, by `<provider>/<model>` (protocol 12). */
12
12
  addedInputs?: Readonly<Record<string, readonly ModelInput[]>>;
13
- authType: "api_key" | "oauth";
13
+ authType: HostModelAuthType;
14
14
  credential: {
15
- type: "api_key" | "oauth";
15
+ type: HostModelAuthType;
16
16
  key?: string;
17
17
  env?: Record<string, string>;
18
18
  refresh?: string;
@@ -22,7 +22,7 @@ export type HostModelSecret = {
22
22
  };
23
23
  };
24
24
  type UserCredentialRow = VaultCredentialRow & {
25
- type: "bearer" | "headers" | "environment_secret" | "environment_variable";
25
+ type: "bearer" | "headers" | "workload" | "environment_secret" | "environment_variable";
26
26
  };
27
27
  /**
28
28
  * A fork's secrets sealed for its new vault (`sealEnvironmentSecretCopies`): each copy's
@@ -54,6 +54,13 @@ export type AuthorizeResult = {
54
54
  headers: Record<string, string>;
55
55
  /** Where the request goes instead of `url` (a gateway), when the credential has one. */
56
56
  via?: string;
57
+ /** How the gateway at `via` prefixes the server's tool names, when it does. */
58
+ toolPrefix?: string;
59
+ /**
60
+ * A `workload` credential (#496): `sessionCredentials` adds the Runtime's workload token as
61
+ * the bearer, on the gate side, where `via`'s origin is one the operator lists.
62
+ */
63
+ workload?: true;
57
64
  /** The header that names the session owner; `sessionCredentials` adds it. */
58
65
  identity?: CredentialIdentity;
59
66
  } | {
@@ -195,7 +202,7 @@ type ModelBinding = {
195
202
  model: string;
196
203
  baseUrl?: string;
197
204
  settings?: CustomModelSettings;
198
- authType: "api_key" | "oauth";
205
+ authType: HostModelAuthType;
199
206
  };
200
207
  type HostSecretPayload = {
201
208
  apiKey?: string;
@@ -83,7 +83,7 @@ import { canonical } from "../store/canonical.js";
83
83
  import { decryptSecret, encryptSecret, VaultCryptoError } from "./crypto.js";
84
84
  import { VaultError } from "./error.js";
85
85
  import { credentialNeeds } from "./coverage.js";
86
- import { checkCredentialHeaders } from "./headers.js";
86
+ import { checkCredentialHeaders, modelHeaderError } from "./headers.js";
87
87
  import { hostModelCatalog, inputsOfHostModel } from "../model/catalog.js";
88
88
  import { inputsError } from "../model/capabilities.js";
89
89
  import { readAddedInputs } from "../tenant/model-setting.js";
@@ -247,7 +247,8 @@ export class VaultService {
247
247
  ({ payload, binding } = environmentCredential(name, body));
248
248
  else {
249
249
  const url = normalizeVaultUrl(body.url);
250
- payload = payloadOf(body);
250
+ // A workload credential holds no value: the bearer is the Runtime's workload token (#496).
251
+ payload = body.type === "workload" ? {} : payloadOf(body);
251
252
  binding = bindingOf(url, payload, body);
252
253
  }
253
254
  const kek = this.kek();
@@ -466,6 +467,8 @@ export class VaultService {
466
467
  url,
467
468
  headers,
468
469
  ...(binding.via === undefined ? {} : { via: binding.via }),
470
+ ...(binding.toolPrefix === undefined ? {} : { toolPrefix: binding.toolPrefix }),
471
+ ...(row.type === "workload" ? { workload: true } : {}),
469
472
  ...(binding.identity === undefined ? {} : { identity: binding.identity }),
470
473
  };
471
474
  }
@@ -541,15 +544,20 @@ export class VaultService {
541
544
  const kek = this.kek();
542
545
  return this.store.tx((t) => this.replay(t, `host-model:${body.idempotencyKey}`, body, async () => {
543
546
  this.validateHostModel(body);
547
+ if (body.auth.type === "workload" && body.provider !== "custom")
548
+ throw new VaultError(400, "The workload token authenticates a custom provider only.");
544
549
  await this.ensureHostVault(t);
545
550
  const credentialId = hostModelCredentialId(body.provider);
546
551
  await this.deleteHostProviderRows(t, body.provider);
552
+ // The workload token is read on every call and never stored (#496).
547
553
  const payload = body.auth.type === "api_key"
548
554
  ? {
549
555
  apiKey: body.auth.key,
550
556
  ...(body.auth.env ? { env: body.auth.env } : {}),
551
557
  }
552
- : { oauth: body.auth };
558
+ : body.auth.type === "oauth"
559
+ ? { oauth: body.auth }
560
+ : {};
553
561
  await this.insertHostCredential(t, kek, {
554
562
  id: credentialId,
555
563
  provider: body.provider,
@@ -673,6 +681,7 @@ export class VaultService {
673
681
  }
674
682
  if (!["http:", "https:"].includes(url.protocol))
675
683
  throw new VaultError(400, "Base URL must be an HTTP(S) URL.");
684
+ checkModelHeaders(body.settings);
676
685
  return;
677
686
  }
678
687
  if (body.baseUrl)
@@ -800,8 +809,9 @@ function conflictOf(type, binding, others) {
800
809
  });
801
810
  }
802
811
  }
803
- else if (type === "bearer" || type === "headers") {
804
- const existing = others.find((row) => (row.type === "bearer" || row.type === "headers") && credentialBinding(row).url === binding.url);
812
+ else if (type === "bearer" || type === "headers" || type === "workload") {
813
+ const existing = others.find((row) => (row.type === "bearer" || row.type === "headers" || row.type === "workload") &&
814
+ credentialBinding(row).url === binding.url);
805
815
  if (existing)
806
816
  throw new VaultError(409, `${existing.name} is already bound to ${binding.url}`, {
807
817
  code: "credential_conflict",
@@ -828,6 +838,7 @@ function secretViewOf(row) {
828
838
  : { url: binding.url },
829
839
  ...(binding.headerNames === undefined ? {} : { headerNames: binding.headerNames }),
830
840
  ...(binding.via === undefined ? {} : { via: binding.via }),
841
+ ...(binding.toolPrefix === undefined ? {} : { toolPrefix: binding.toolPrefix }),
831
842
  ...(binding.identity === undefined ? {} : { identity: binding.identity }),
832
843
  createdAt: row.createdAt,
833
844
  ...(row.rotatedAt ? { rotatedAt: row.rotatedAt } : {}),
@@ -909,6 +920,23 @@ export function readHostPayload(kek, row, binding) {
909
920
  plaintext.fill(0);
910
921
  }
911
922
  }
923
+ /**
924
+ * A custom endpoint's `headers` and `callHeaders` (#493): no name the model's auth or the
925
+ * transport sets, and each name once across both maps, whatever its case.
926
+ */
927
+ function checkModelHeaders(settings) {
928
+ const names = [...Object.keys(settings?.headers ?? {}), ...Object.keys(settings?.callHeaders ?? {})];
929
+ const seen = new Set();
930
+ for (const name of names) {
931
+ const refused = modelHeaderError(name);
932
+ if (refused)
933
+ throw new VaultError(400, refused);
934
+ const lower = name.toLowerCase();
935
+ if (seen.has(lower))
936
+ throw new VaultError(400, `The model header ${lower} is set twice`);
937
+ seen.add(lower);
938
+ }
939
+ }
912
940
  /** The sealed part of a tool credential: a token, or a header map with lower-cased names. */
913
941
  function payloadOf(auth) {
914
942
  if (auth.type === "bearer")
@@ -924,12 +952,16 @@ function bindingOf(url, payload, routing) {
924
952
  return {
925
953
  url,
926
954
  ...(routing.via ? { via: routing.via } : {}),
955
+ ...(routing.via && routing.toolPrefix ? { toolPrefix: routing.toolPrefix } : {}),
927
956
  ...(routing.identity ? { identity: { header: routing.identity.header.toLowerCase() } } : {}),
928
957
  ...(payload.headers ? { headerNames: sent } : {}),
929
958
  };
930
959
  }
931
960
  /** The headers a credential sends, or undefined when its payload holds none. */
932
961
  function sendHeaders(row, payload) {
962
+ // Its bearer is the Runtime's workload token, which `sessionCredentials` adds.
963
+ if (row.type === "workload")
964
+ return {};
933
965
  if (row.type === "headers")
934
966
  return payload.headers && Object.keys(payload.headers).length > 0
935
967
  ? { ...payload.headers }
@@ -999,7 +1031,12 @@ function rebind(name, slot, row, payload, binding) {
999
1031
  return undefined;
1000
1032
  const sealed = payloadOf(auth);
1001
1033
  const routing = slot;
1002
- return { type, payload: sealed, binding: bindingOf(normalizeVaultUrl(routing.url), sealed, routing) };
1034
+ // The gateway's tool prefix is the vault's, not the slot's: it stays while the slot keeps `via`.
1035
+ return {
1036
+ type,
1037
+ payload: sealed,
1038
+ binding: bindingOf(normalizeVaultUrl(routing.url), sealed, { ...routing, toolPrefix: binding.toolPrefix ?? null }),
1039
+ };
1003
1040
  }
1004
1041
  catch (error) {
1005
1042
  if (error instanceof VaultError)
@@ -8,6 +8,7 @@
8
8
  * and Tool Gate). The session comes from its row, never from the harness.
9
9
  */
10
10
  import { isSubject } from "@nylorun/core/contracts";
11
+ import { workloadOriginRefused, workloadToken, WorkloadTokenUnavailable } from "./workload-token.js";
11
12
  /**
12
13
  * The credential of one request made for `session`: the answer of its Environment's vault
13
14
  * (`VaultService.authorize`). A credential with an identity header (R2b C2)
@@ -16,11 +17,12 @@ import { isSubject } from "@nylorun/core/contracts";
16
17
  * person (the reserved `installation`) sends none (Q3).
17
18
  */
18
19
  export async function sessionCredentials(vault, session, request) {
19
- const result = await vault.authorize({
20
+ const authorized = await vault.authorize({
20
21
  sessionId: session.id,
21
22
  ...(session.environmentId === undefined ? {} : { environmentId: session.environmentId }),
22
23
  url: request.url,
23
24
  });
25
+ const result = authorized.status === "authorized" && authorized.workload ? await withWorkloadToken(authorized) : authorized;
24
26
  if (result.status !== "authorized" || !result.identity || !isSubject(session.ownerUserId))
25
27
  return result;
26
28
  return {
@@ -28,3 +30,22 @@ export async function sessionCredentials(vault, session, request) {
28
30
  headers: { ...result.headers, [result.identity.header]: session.ownerUserId },
29
31
  };
30
32
  }
33
+ /**
34
+ * A `workload` credential's request with the Runtime's workload token as its bearer, read now
35
+ * (#496), or refused: when `via`'s origin is not one the operator lists, or the token cannot be
36
+ * read. Never sent without it.
37
+ */
38
+ async function withWorkloadToken(result) {
39
+ const refused = (reason) => ({ status: "refused", url: result.url, credentialIds: [], reason });
40
+ const origin = result.via === undefined ? "a workload credential needs via" : workloadOriginRefused(result.via);
41
+ if (origin !== undefined)
42
+ return refused(origin);
43
+ try {
44
+ return { ...result, headers: { ...result.headers, authorization: `Bearer ${await workloadToken()}` } };
45
+ }
46
+ catch (error) {
47
+ if (error instanceof WorkloadTokenUnavailable)
48
+ return refused(error.message);
49
+ throw error;
50
+ }
51
+ }
@@ -0,0 +1,16 @@
1
+ export interface WorkloadTokenSettings {
2
+ /** The token's file (`NYLORUN_WORKLOAD_TOKEN_FILE`). */
3
+ readonly file?: string;
4
+ /** The origins a `workload` credential's `via` may name (`NYLORUN_WORKLOAD_TOKEN_ORIGINS`). */
5
+ readonly origins?: readonly string[];
6
+ }
7
+ /** Why the workload token could not be used: the message names the setting, never the token. */
8
+ export declare class WorkloadTokenUnavailable extends Error {
9
+ constructor(message: string);
10
+ }
11
+ /** Sets where this process reads the workload token and where it may send it (at startup). */
12
+ export declare function useWorkloadToken(next: WorkloadTokenSettings): void;
13
+ /** The workload token, read from its file now. */
14
+ export declare function workloadToken(): Promise<string>;
15
+ /** Why the workload token may not go to `via`, or undefined when its origin is listed. */
16
+ export declare function workloadOriginRefused(via: string): string | undefined;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The Runtime's workload token (#496): a short-lived token a platform gives each installation and
3
+ * rotates through a file, so a gateway trusts the workload instead of a stored key. The file
4
+ * (`NYLORUN_WORKLOAD_TOKEN_FILE`) is read on every use and nothing of it is kept, so a rotation
5
+ * applies to the next request with no restart. A missing, unreadable or empty file fails the
6
+ * request, never sends it unauthenticated.
7
+ *
8
+ * Only the gate side reads it: the model call and the MCP and HTTP tool requests a `workload`
9
+ * credential makes. It never reaches a sandbox (`NYLORUN_*` is not passed to one), the model, an
10
+ * event or a log, and requests scrub it like any credential value. An Agent's writer chooses a
11
+ * credential's `via`, so the token goes only to the origins the operator lists
12
+ * (`NYLORUN_WORKLOAD_TOKEN_ORIGINS`), checked on every use; the model's `baseUrl` is the
13
+ * operator's own.
14
+ */
15
+ import { readFile } from "node:fs/promises";
16
+ /** Why the workload token could not be used: the message names the setting, never the token. */
17
+ export class WorkloadTokenUnavailable extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "WorkloadTokenUnavailable";
21
+ }
22
+ }
23
+ let settings = {};
24
+ /** Sets where this process reads the workload token and where it may send it (at startup). */
25
+ export function useWorkloadToken(next) {
26
+ settings = { ...next };
27
+ }
28
+ /** The workload token, read from its file now. */
29
+ export async function workloadToken() {
30
+ const file = settings.file;
31
+ if (file === undefined)
32
+ throw new WorkloadTokenUnavailable("The Runtime has no workload token: set NYLORUN_WORKLOAD_TOKEN_FILE");
33
+ let text;
34
+ try {
35
+ text = await readFile(file, "utf8");
36
+ }
37
+ catch (error) {
38
+ const code = error.code ?? "unreadable";
39
+ throw new WorkloadTokenUnavailable(`The workload token file ${file} (NYLORUN_WORKLOAD_TOKEN_FILE) cannot be read: ${code}`);
40
+ }
41
+ const token = text.trim();
42
+ if (token.length === 0)
43
+ throw new WorkloadTokenUnavailable(`The workload token file ${file} (NYLORUN_WORKLOAD_TOKEN_FILE) is empty`);
44
+ return token;
45
+ }
46
+ /** Why the workload token may not go to `via`, or undefined when its origin is listed. */
47
+ export function workloadOriginRefused(via) {
48
+ let origin;
49
+ try {
50
+ origin = new URL(via).origin;
51
+ }
52
+ catch {
53
+ return `${via} is not a URL`;
54
+ }
55
+ return (settings.origins ?? []).includes(origin)
56
+ ? undefined
57
+ : `The workload token may not go to ${origin}: list it in NYLORUN_WORKLOAD_TOKEN_ORIGINS`;
58
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.29.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.30.0-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/server/scripts/check-package.mjs, which runs in npm run check.
2
2
  // release:prepare also rewrites this when it bumps @nylorun/runtime.
3
- export const RUNTIME_VERSION = "0.29.0-beta";
3
+ export const RUNTIME_VERSION = "0.30.0-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.29.0-beta",
3
+ "version": "0.30.0-beta",
4
4
  "description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -65,8 +65,8 @@
65
65
  "@hono/node-server": "^2.1.5",
66
66
  "@hono/zod-openapi": "^1.6.3",
67
67
  "@modelcontextprotocol/sdk": "^1.32.1",
68
- "@nylorun/core": "0.25.0-beta",
69
- "@nylorun/harness": "0.29.1-beta",
68
+ "@nylorun/core": "0.26.0-beta",
69
+ "@nylorun/harness": "0.29.2-beta",
70
70
  "@restatedev/restate-sdk": "1.17.3",
71
71
  "@restatedev/restate-sdk-clients": "1.17.3",
72
72
  "@s2-dev/streamstore": "0.28.0",