@nylorun/runtime 0.37.0-beta → 0.39.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 (51) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/api/components.d.ts +1732 -80
  3. package/dist/api/http/routes/agents.js +3 -3
  4. package/dist/core/turn-manifest.d.ts +23 -0
  5. package/dist/core/turn-manifest.js +36 -1
  6. package/dist/gates/policy.d.ts +13 -3
  7. package/dist/gates/policy.js +17 -4
  8. package/dist/harness/executors.d.ts +4 -2
  9. package/dist/harness/executors.js +26 -4
  10. package/dist/harness/image-engine.d.ts +14 -0
  11. package/dist/harness/image-engine.js +16 -0
  12. package/dist/harness/pod.d.ts +2 -0
  13. package/dist/harness/pod.js +5 -0
  14. package/dist/harness-api/record.js +4 -2
  15. package/dist/harness-api/server.js +37 -13
  16. package/dist/harness-api/start.js +43 -5
  17. package/dist/harness-api/ws-server.js +7 -1
  18. package/dist/keys/keys.d.ts +6 -3
  19. package/dist/keys/keys.js +1 -1
  20. package/dist/management-openapi.json +38 -4
  21. package/dist/mcp/pool.d.ts +5 -0
  22. package/dist/mcp/pool.js +1 -1
  23. package/dist/mcp/snapshot.d.ts +3 -1
  24. package/dist/mcp/snapshot.js +5 -3
  25. package/dist/openapi.json +1636 -563
  26. package/dist/sandbox/join.d.ts +7 -1
  27. package/dist/sandbox/join.js +5 -1
  28. package/dist/sandbox/pods/builtin.d.ts +18 -0
  29. package/dist/sandbox/pods/builtin.js +33 -0
  30. package/dist/sandbox/pods/client.d.ts +5 -4
  31. package/dist/sandbox/pods/spec.js +14 -1
  32. package/dist/store/postgres/drizzle/0042_worker_engine_image.sql +1 -0
  33. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  34. package/dist/store/postgres/schema.d.ts +19 -0
  35. package/dist/store/postgres/schema.js +2 -0
  36. package/dist/store/postgres/store.js +16 -0
  37. package/dist/store/types.d.ts +18 -0
  38. package/dist/tenant/agent-versions.d.ts +13 -5
  39. package/dist/tenant/agent-versions.js +30 -6
  40. package/dist/tenant/agents.d.ts +14 -2
  41. package/dist/tenant/agents.js +51 -13
  42. package/dist/tenant/commands.js +16 -8
  43. package/dist/tenant/context.d.ts +30 -1
  44. package/dist/tenant/session-model.d.ts +35 -6
  45. package/dist/tenant/session-model.js +77 -9
  46. package/dist/tenant/sessions.js +13 -3
  47. package/dist/vault/service.d.ts +44 -3
  48. package/dist/vault/service.js +68 -27
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/package.json +3 -3
@@ -4,10 +4,15 @@
4
4
  * (`Session.model`): the Model Gate reads it at each call (`HostModelVault.readHostModel`) and a
5
5
  * turn's start describes it (`turn.start.model`). The Tenant's model, and every other session's,
6
6
  * stay as they are. While its provider is disconnected the session uses the Tenant's model again.
7
+ *
8
+ * What its turns call, and why, is kept too (`Session.modelRecord`, `SessionModelRecord`): set when
9
+ * the session opens (the manifest's model, or the Tenant's and why), when it chooses or clears its
10
+ * model, and again at each segment's start, when a provider was connected or disconnected or the
11
+ * Tenant's model changed. Each change writes `session.model`.
7
12
  */
8
- import { type SessionModelChoice, type SessionModelView } from "@nylorun/core/contracts";
13
+ import { type SessionModelChoice, type SessionModelRecord, type SessionModelView } from "@nylorun/core/contracts";
9
14
  import type { Tx } from "../store/types.js";
10
- import { type SessionAccess, type TenantContext } from "./context.js";
15
+ import { type Session, type SessionAccess, type TenantContext } from "./context.js";
11
16
  /** The model the session's next turn uses, and where it comes from. */
12
17
  export declare function readSessionModel(ctx: TenantContext, id: string, access: SessionAccess | undefined): Promise<SessionModelView>;
13
18
  /**
@@ -15,12 +20,36 @@ export declare function readSessionModel(ctx: TenantContext, id: string, access:
15
20
  * Tenant's. A turn calls one model: the choice changes only while no turn runs or waits.
16
21
  */
17
22
  export declare function chooseSessionModel(ctx: TenantContext, id: string, choice: SessionModelChoice | undefined, access: SessionAccess | undefined): Promise<SessionModelView>;
23
+ /** Why a session's turns call the Tenant's model in place of the one asked for, and that one. */
24
+ export interface ModelFallback {
25
+ readonly reason: NonNullable<SessionModelRecord["reason"]>;
26
+ readonly requested: NonNullable<SessionModelRecord["requested"]>;
27
+ }
18
28
  /**
19
29
  * The model a new session starts with (Host feature `agent-model`): the one its version's
20
- * `harness.model` names, as `chooseSessionModel` would take it, or undefined when it names none or
21
- * one the Tenant cannot use now (a provider not connected, a model its catalog does not list), so
22
- * the Tenant's model applies. Never fails the session.
30
+ * `harness.model` names, as `chooseSessionModel` would take it (`choice`), or, when it names one
31
+ * the Tenant cannot use now (a provider not connected, a model its catalog does not list), why
32
+ * (`fallback`), so the Tenant's model applies. Undefined when it names none. Never fails the
33
+ * session.
23
34
  */
24
35
  export declare function agentModelOf(t: Tx, harness: {
25
36
  readonly model?: unknown;
26
- }): Promise<SessionModelChoice | undefined>;
37
+ }): Promise<{
38
+ choice: SessionModelChoice;
39
+ } | {
40
+ fallback: ModelFallback;
41
+ } | undefined>;
42
+ /**
43
+ * The model session `s`'s turns call now, and why (`SessionModelRecord`, as the Model Gate picks
44
+ * it: `hostCredentialRowOf`): its own model while its provider is connected (`source` `manifest`
45
+ * when its Agent's version named it, else `session`), else the Tenant's, with the reason when it
46
+ * falls back. `fallback` is why the session opened without the model its manifest names; kept
47
+ * from the session's record otherwise, until it chooses or clears a model. Undefined while the
48
+ * Tenant has no model to fall back to.
49
+ */
50
+ export declare function sessionModelRecordOf(t: Tx, s: Pick<Session, "model" | "modelSource" | "modelRecord">, fallback?: ModelFallback): Promise<SessionModelRecord | undefined>;
51
+ /**
52
+ * Keeps `record` as session `s`'s model record (the caller saves `s`) and, when it differs from
53
+ * the one it had, writes `session.model` with the one before. Returns whether it changed.
54
+ */
55
+ export declare function noteSessionModel(t: Tx, s: Session, record: SessionModelRecord | undefined): Promise<boolean>;
@@ -4,10 +4,16 @@
4
4
  * (`Session.model`): the Model Gate reads it at each call (`HostModelVault.readHostModel`) and a
5
5
  * turn's start describes it (`turn.start.model`). The Tenant's model, and every other session's,
6
6
  * stay as they are. While its provider is disconnected the session uses the Tenant's model again.
7
+ *
8
+ * What its turns call, and why, is kept too (`Session.modelRecord`, `SessionModelRecord`): set when
9
+ * the session opens (the manifest's model, or the Tenant's and why), when it chooses or clears its
10
+ * model, and again at each segment's start, when a provider was connected or disconnected or the
11
+ * Tenant's model changed. Each change writes `session.model`.
7
12
  */
8
- import { agentModelChoice } from "@nylorun/core/contracts";
13
+ import { agentModelChoice, } from "@nylorun/core/contracts";
14
+ import { canonical } from "../store/canonical.js";
9
15
  import { catalogModel, hostModelCatalog } from "../model/catalog.js";
10
- import { hostCredentialRowFor, modelBinding } from "../vault/service.js";
16
+ import { activeHostCredentialRow, hostCredentialRowFor, modelBinding } from "../vault/service.js";
11
17
  import { takesMessages } from "./commands.js";
12
18
  import { lockedSession, sessionOf } from "./context.js";
13
19
  import { fail } from "./http.js";
@@ -29,8 +35,9 @@ export async function chooseSessionModel(ctx, id, choice, access) {
29
35
  });
30
36
  if (choice)
31
37
  await checkChoice(t, choice);
32
- const { model: _model, ...rest } = s;
38
+ const { model: _model, modelSource: _source, ...rest } = s;
33
39
  const next = choice ? { ...rest, model: { provider: choice.provider, model: choice.model } } : rest;
40
+ await noteSessionModel(t, next, await sessionModelRecordOf(t, next));
34
41
  await t.put("sessions", id, next);
35
42
  return next;
36
43
  });
@@ -38,15 +45,70 @@ export async function chooseSessionModel(ctx, id, choice, access) {
38
45
  }
39
46
  /**
40
47
  * The model a new session starts with (Host feature `agent-model`): the one its version's
41
- * `harness.model` names, as `chooseSessionModel` would take it, or undefined when it names none or
42
- * one the Tenant cannot use now (a provider not connected, a model its catalog does not list), so
43
- * the Tenant's model applies. Never fails the session.
48
+ * `harness.model` names, as `chooseSessionModel` would take it (`choice`), or, when it names one
49
+ * the Tenant cannot use now (a provider not connected, a model its catalog does not list), why
50
+ * (`fallback`), so the Tenant's model applies. Undefined when it names none. Never fails the
51
+ * session.
44
52
  */
45
53
  export async function agentModelOf(t, harness) {
46
54
  if (typeof harness.model !== "string")
47
55
  return undefined;
48
56
  const choice = agentModelChoice(harness.model);
49
- return choice && (await choiceIssue(t, choice)) === undefined ? choice : undefined;
57
+ if (!choice)
58
+ return undefined;
59
+ const issue = await choiceIssue(t, choice);
60
+ return issue === undefined
61
+ ? { choice }
62
+ : { fallback: { reason: issue.reason, requested: { ...choice, by: "manifest" } } };
63
+ }
64
+ /**
65
+ * The model session `s`'s turns call now, and why (`SessionModelRecord`, as the Model Gate picks
66
+ * it: `hostCredentialRowOf`): its own model while its provider is connected (`source` `manifest`
67
+ * when its Agent's version named it, else `session`), else the Tenant's, with the reason when it
68
+ * falls back. `fallback` is why the session opened without the model its manifest names; kept
69
+ * from the session's record otherwise, until it chooses or clears a model. Undefined while the
70
+ * Tenant has no model to fall back to.
71
+ */
72
+ export async function sessionModelRecordOf(t, s, fallback) {
73
+ if (s.model) {
74
+ const by = s.modelSource ?? "session";
75
+ if (await hostCredentialRowFor(t, s.model.provider))
76
+ return { provider: s.model.provider, model: s.model.model, source: by };
77
+ return tenantModelRecord(t, { reason: "provider_not_connected", requested: { ...s.model, by } });
78
+ }
79
+ const kept = fallback ??
80
+ (s.modelRecord?.reason !== undefined && s.modelRecord.requested?.by === "manifest"
81
+ ? { reason: s.modelRecord.reason, requested: s.modelRecord.requested }
82
+ : undefined);
83
+ return tenantModelRecord(t, kept);
84
+ }
85
+ async function tenantModelRecord(t, fallback) {
86
+ const row = await activeHostCredentialRow(t);
87
+ if (!row)
88
+ return undefined;
89
+ const binding = modelBinding(row);
90
+ return {
91
+ provider: binding.provider,
92
+ model: binding.model,
93
+ source: "tenant",
94
+ ...(fallback === undefined ? {} : { reason: fallback.reason, requested: { ...fallback.requested } }),
95
+ };
96
+ }
97
+ /**
98
+ * Keeps `record` as session `s`'s model record (the caller saves `s`) and, when it differs from
99
+ * the one it had, writes `session.model` with the one before. Returns whether it changed.
100
+ */
101
+ export async function noteSessionModel(t, s, record) {
102
+ if (canonical(record ?? null) === canonical(s.modelRecord ?? null))
103
+ return false;
104
+ const previous = s.modelRecord;
105
+ if (record === undefined) {
106
+ delete s.modelRecord;
107
+ return true;
108
+ }
109
+ s.modelRecord = record;
110
+ await t.event(s.id, s.activeTurnId, "session.model", { ...record, ...(previous ? { previous } : {}) });
111
+ return true;
50
112
  }
51
113
  /** A choice names a connected provider and, when its catalog lists models, one of them. */
52
114
  async function checkChoice(t, choice) {
@@ -57,19 +119,25 @@ async function checkChoice(t, choice) {
57
119
  /** Why `choice` is not one the Tenant can use now; undefined when it is. */
58
120
  async function choiceIssue(t, choice) {
59
121
  if (!/^[a-zA-Z0-9._-]+$/.test(choice.provider))
60
- return { status: 400, message: "Invalid provider id", code: "invalid_request" };
122
+ return { status: 400, message: "Invalid provider id", code: "invalid_request", reason: "invalid_model" };
61
123
  const row = await hostCredentialRowFor(t, choice.provider);
62
124
  if (!row)
63
125
  return {
64
126
  status: 422,
65
127
  message: `Model provider ${choice.provider} is not connected: connect it in Model Settings first`,
66
128
  code: "request_rejected",
129
+ reason: "provider_not_connected",
67
130
  };
68
131
  // A custom endpoint serves the models it names; the provider's own model is always one.
69
132
  if (choice.provider === "custom" || modelBinding(row).model === choice.model)
70
133
  return undefined;
71
134
  const listed = hostModelCatalog().providers.find((provider) => provider.id === choice.provider);
72
135
  if (listed && listed.models.length > 0 && !catalogModel(choice.provider, choice.model))
73
- return { status: 422, message: `Unknown model ${choice.model} for provider ${choice.provider}`, code: "request_rejected" };
136
+ return {
137
+ status: 422,
138
+ message: `Unknown model ${choice.model} for provider ${choice.provider}`,
139
+ code: "request_rejected",
140
+ reason: "not_in_catalog",
141
+ };
74
142
  return undefined;
75
143
  }
@@ -15,7 +15,7 @@ import { openAgent, recordAttachment } from "./agents.js";
15
15
  import { harnessOf, reachesAgent, } from "./context.js";
16
16
  import { fail } from "./http.js";
17
17
  import { definitionFileIssues } from "./definition-files.js";
18
- import { agentModelOf } from "./session-model.js";
18
+ import { agentModelOf, noteSessionModel, sessionModelRecordOf } from "./session-model.js";
19
19
  import { limitsInForce, readTenantLimits } from "./limits.js";
20
20
  /** A session's creation identity: everything but the request id. */
21
21
  const sessionIdentity = (value) => {
@@ -98,7 +98,8 @@ export async function openSessionIn(ctx, t, id, body, access, options = {}) {
98
98
  await requirePods(ctx.pods, `Agent ${agent.id}`);
99
99
  // The model the version names (`harness.model`, Host feature `agent-model`) is the new
100
100
  // session's own, as `chooseSessionModel` would make it, when the Tenant can use it now.
101
- const model = await agentModelOf(t, version.body.harness);
101
+ const asked = await agentModelOf(t, version.body.harness);
102
+ const model = asked && "choice" in asked ? asked.choice : undefined;
102
103
  const created = {
103
104
  id,
104
105
  agentId: agent.id,
@@ -115,10 +116,16 @@ export async function openSessionIn(ctx, t, id, body, access, options = {}) {
115
116
  environmentId: agent.id,
116
117
  environmentVersion: agent.version,
117
118
  ...(pin.sandbox === undefined ? {} : { sandbox: pin.sandbox }),
118
- ...(model === undefined ? {} : { model }),
119
+ ...(model === undefined ? {} : { model, modelSource: "manifest" }),
119
120
  };
120
121
  await t.put("sessions", id, created);
121
122
  await recordAttachment(t, id, agent.id);
123
+ // The model its turns call, and why: a flow's agents call theirs in their own sessions.
124
+ if (!isWorkflowManifest(pin.manifest)) {
125
+ const fallback = asked && "fallback" in asked ? asked.fallback : undefined;
126
+ if (await noteSessionModel(t, created, await sessionModelRecordOf(t, created, fallback)))
127
+ await t.put("sessions", id, created);
128
+ }
122
129
  return created;
123
130
  }
124
131
  /**
@@ -165,6 +172,9 @@ export async function sessionView(t, s) {
165
172
  ...(s.closedReason === undefined ? {} : { closedReason: s.closedReason }),
166
173
  mcpSnapshot: s.mcpSnapshot ?? null,
167
174
  mcpDiagnostics: s.mcpDiagnostics ?? [],
175
+ // As its last turn started; a session that kept none yet (older, or opened before the
176
+ // Tenant had a model) answers what its next turn would call.
177
+ model: isWorkflowManifest(s.manifest) ? null : (s.modelRecord ?? (await sessionModelRecordOf(t, s)) ?? null),
168
178
  waits,
169
179
  error: s.error,
170
180
  uncertainEffects: uncertain.map((e) => ({
@@ -1,4 +1,4 @@
1
- import type { CredentialCoverage, CredentialCoverageRequest, CredentialIdentity, EnvironmentSecretSlot, AgentSecretView, HostModelAuthType, HostModelProviderInfo, HostModelView, SessionModelChoice, SessionModelView, PutAgentSecretRequest, PutHostModelRequest, CustomModelSettings, ModelInput, SelectHostModelRequest } from "@nylorun/core/contracts";
1
+ import type { CredentialCoverage, CredentialCoverageRequest, CredentialIdentity, EnvironmentSecretInject, EnvironmentSecretSlot, AgentSecretView, HostModelAuthType, HostModelProviderInfo, HostModelView, SessionModelChoice, SessionModelView, 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";
@@ -43,6 +43,35 @@ export interface SealedSecretCopies {
43
43
  /** Names left behind: no slot of the fork declares them, or their slot cannot take the value. */
44
44
  readonly dropped: readonly string[];
45
45
  }
46
+ /** A user credential's `bindingJson`: everything about it that is not secret. */
47
+ type CredentialBinding = {
48
+ /** A `bearer` or `headers` credential's server URL; absent for the environment kinds. */
49
+ url?: string;
50
+ /** An `environment_secret`'s variable, hosts and header (R2c); sealed into its AAD. */
51
+ secretName?: string;
52
+ allowedHosts?: string[];
53
+ inject?: EnvironmentSecretInject;
54
+ /** An `environment_variable`'s name and plain value (R2c): not a secret. */
55
+ variableName?: string;
56
+ variableValue?: string;
57
+ via?: string;
58
+ /**
59
+ * `slot`: `via` is the secret slot's, copied when it was written, not the write's own
60
+ * (`secret-routing`): a request follows the slot of the version the Agent runs (`routingOf`).
61
+ * Absent: `via` is the secret's own (or, written before the marker, the slot's then).
62
+ */
63
+ viaFrom?: "slot";
64
+ /** How the gateway at `via` prefixes tool names (`gateway-credentials`). */
65
+ toolPrefix?: string;
66
+ identity?: CredentialIdentity;
67
+ /** A `headers` credential's names, lower-cased. */
68
+ headerNames?: string[];
69
+ };
70
+ /** How a secret write was made (`putEnvironmentSecret`); `null` crosses the keys wire as none. */
71
+ export interface SecretWriteOptions {
72
+ /** The body's `via` is its secret slot's, not the write's own (`secret-routing`). */
73
+ readonly viaFromSlot?: boolean;
74
+ }
46
75
  export type AuthorizeResult = {
47
76
  status: "unauthenticated";
48
77
  url: string;
@@ -114,8 +143,8 @@ export declare class VaultService {
114
143
  * bound to one of its hosts or to its URL. Runs under the Environment row's lock, which also
115
144
  * orders it with session opens and deletes, and counts as the Environment's use.
116
145
  */
117
- putEnvironmentSecret(environmentId: string, name: string, body: PutAgentSecretRequest, actor: string): Promise<AgentSecretView>;
118
- /** The Environment's secrets: names, types and bindings, never values. */
146
+ putEnvironmentSecret(environmentId: string, name: string, body: PutAgentSecretRequest, actor: string, options?: SecretWriteOptions | null): Promise<AgentSecretView>;
147
+ /** The Environment's secrets: names, types, bindings and the routing in force, never values. */
119
148
  listEnvironmentSecrets(environmentId: string): Promise<AgentSecretView[]>;
120
149
  getEnvironmentSecret(environmentId: string, name: string): Promise<AgentSecretView>;
121
150
  deleteEnvironmentSecret(environmentId: string, name: string, actor: string): Promise<{
@@ -232,4 +261,16 @@ export declare function hostModelAad(provider: string, model: string): Buffer;
232
261
  * binding (the hosts and header of an `environment_secret`, the URL of a `bearer` or `headers`).
233
262
  */
234
263
  export declare function slotHolds(slot: EnvironmentSecretSlot, row: Pick<VaultCredentialRow, "type" | "bindingJson">): boolean;
264
+ /**
265
+ * Where a `bearer` or `headers` credential's requests go, and how the gateway there names tools,
266
+ * at the time of the request (Host feature `secret-routing`), under `slot`, the slot it fills of
267
+ * the version its Agent runs: `via` is the secret's own (written with it), else the slot's, else
268
+ * none (the request goes to the URL); a `via` copied from the slot (`viaFrom: "slot"`) follows the
269
+ * slot. `toolPrefix` is the slot's, else the secret's, and only with a `via`. The credential is
270
+ * chosen by the URL either way.
271
+ */
272
+ export declare function routingOf(binding: Pick<CredentialBinding, "via" | "viaFrom" | "toolPrefix">, slot?: EnvironmentSecretSlot): {
273
+ via?: string;
274
+ toolPrefix?: string;
275
+ };
235
276
  export {};
@@ -239,7 +239,7 @@ export class VaultService {
239
239
  * bound to one of its hosts or to its URL. Runs under the Environment row's lock, which also
240
240
  * orders it with session opens and deletes, and counts as the Environment's use.
241
241
  */
242
- async putEnvironmentSecret(environmentId, name, body, actor) {
242
+ async putEnvironmentSecret(environmentId, name, body, actor, options) {
243
243
  const invalid = environmentSecretNameError(body.type, name);
244
244
  if (invalid)
245
245
  throw new VaultError(400, invalid);
@@ -252,6 +252,8 @@ export class VaultService {
252
252
  // A workload credential holds no value: the bearer is the Runtime's workload token (#496).
253
253
  payload = body.type === "workload" ? {} : payloadOf(body);
254
254
  binding = bindingOf(url, payload, body);
255
+ if (options?.viaFromSlot === true && binding.via !== undefined && body.type !== "workload")
256
+ binding.viaFrom = "slot";
255
257
  }
256
258
  const kek = this.kek();
257
259
  return this.store.tx(async (t) => {
@@ -289,17 +291,18 @@ export class VaultService {
289
291
  await this.audit(t, { actor, action: "create", vaultId: vault.id, credentialId: id, outcome: "created" });
290
292
  }
291
293
  await t.updateEnvironment(environmentId, { lastUsedAt: now }, now);
292
- return secretViewOf(row);
294
+ return secretViewOf(row, slotOf(await appliedSlots(t, environmentId), row));
293
295
  });
294
296
  }
295
- /** The Environment's secrets: names, types and bindings, never values. */
297
+ /** The Environment's secrets: names, types, bindings and the routing in force, never values. */
296
298
  async listEnvironmentSecrets(environmentId) {
297
299
  return this.store.tx(async (t) => {
298
300
  const vault = await this.environmentVault(t, environmentId);
301
+ const slots = await appliedSlots(t, environmentId);
299
302
  return (await t.credentialsForVault(vault.id))
300
303
  .filter(isUserCredential)
301
304
  .sort((a, b) => a.name.localeCompare(b.name))
302
- .map(secretViewOf);
305
+ .map((row) => secretViewOf(row, slotOf(slots, row)));
303
306
  });
304
307
  }
305
308
  async getEnvironmentSecret(environmentId, name) {
@@ -308,7 +311,7 @@ export class VaultService {
308
311
  const row = await t.credentialByName(vault.id, name);
309
312
  if (!row || !isUserCredential(row))
310
313
  throw new VaultError(404, "Secret not found");
311
- return secretViewOf(row);
314
+ return secretViewOf(row, slotOf(await appliedSlots(t, environmentId), row));
312
315
  });
313
316
  }
314
317
  async deleteEnvironmentSecret(environmentId, name, actor) {
@@ -404,7 +407,8 @@ export class VaultService {
404
407
  });
405
408
  return { result: { status: "refused", url, credentialIds: [], reason: "vault_missing" } };
406
409
  }
407
- const live = await liveFilter(t, environmentId);
410
+ const slots = await appliedSlots(t, environmentId);
411
+ const live = liveFilterOf(slots);
408
412
  const matches = (await t.credentialsForVault(vault.id))
409
413
  .filter(isUserCredential)
410
414
  .filter(live)
@@ -426,7 +430,9 @@ export class VaultService {
426
430
  result: { status: "refused", url, credentialIds: matches.map((row) => row.id), reason: "ambiguous" },
427
431
  };
428
432
  }
429
- return { row: matches[0] };
433
+ const row = matches[0];
434
+ const slot = slotOf(slots, row);
435
+ return slot === undefined ? { row } : { row, slot };
430
436
  });
431
437
  if ("result" in decided)
432
438
  return decided.result;
@@ -468,8 +474,8 @@ export class VaultService {
468
474
  status: "authorized",
469
475
  url,
470
476
  headers,
471
- ...(binding.via === undefined ? {} : { via: binding.via }),
472
- ...(binding.toolPrefix === undefined ? {} : { toolPrefix: binding.toolPrefix }),
477
+ // The secret's via, else its slot's; the slot's tool prefix, else the secret's (`secret-routing`).
478
+ ...routingOf(binding, decided.slot),
473
479
  ...(row.type === "workload" ? { workload: true } : {}),
474
480
  ...(binding.identity === undefined ? {} : { identity: binding.identity }),
475
481
  };
@@ -486,10 +492,13 @@ export class VaultService {
486
492
  const credentials = [];
487
493
  {
488
494
  const vault = await this.environmentVault(t, environmentId);
489
- const live = await liveFilter(t, environmentId);
495
+ const slots = environmentId === undefined ? undefined : await appliedSlots(t, environmentId);
496
+ const live = liveFilterOf(slots);
490
497
  for (const row of await t.credentialsForVault(vault.id))
491
- if (isUserCredential(row) && live(row))
492
- credentials.push({ row, url: bindingUrl(row) });
498
+ if (isUserCredential(row) && live(row)) {
499
+ const slot = slotOf(slots, row);
500
+ credentials.push({ row, url: bindingUrl(row), ...(slot === undefined ? {} : { slot }) });
501
+ }
493
502
  }
494
503
  const entries = credentialNeeds(manifest).map((need) => {
495
504
  let url;
@@ -502,7 +511,7 @@ export class VaultService {
502
511
  return coverageEntry(need, need.url, undefined, environmentId, error.message);
503
512
  }
504
513
  const bound = credentials.find((item) => item.url === url);
505
- return coverageEntry(need, url, bound && coverageCredential(environmentId, bound.row), environmentId);
514
+ return coverageEntry(need, url, bound && coverageCredential(environmentId, bound.row, bound.slot), environmentId);
506
515
  });
507
516
  return {
508
517
  agentId: request.agentId,
@@ -791,9 +800,9 @@ function isUserCredential(row) {
791
800
  function bindingUrl(row) {
792
801
  return JSON.parse(row.bindingJson).url;
793
802
  }
794
- /** Where a credential is, for coverage: never its value. */
795
- function coverageCredential(environmentId, row) {
796
- const via = JSON.parse(row.bindingJson).via;
803
+ /** Where a credential is, for coverage, with the `via` in force (`routingOf`): never its value. */
804
+ function coverageCredential(environmentId, row, slot) {
805
+ const { via } = routingOf(credentialBinding(row), slot);
797
806
  return { agentId: environmentId, name: row.name, ...(via === undefined ? {} : { via }) };
798
807
  }
799
808
  /** One coverage entry, with the sentence that says what the session would do. */
@@ -853,9 +862,13 @@ function conflictOf(type, binding, others) {
853
862
  function credentialBinding(row) {
854
863
  return JSON.parse(row.bindingJson);
855
864
  }
856
- /** A secret as `GET .../secrets/{name}` shows it: never its value. */
857
- function secretViewOf(row) {
865
+ /**
866
+ * A secret as `GET .../secrets/{name}` shows it, with the routing in force under `slot`, the
867
+ * slot of the version its Agent runs that it fills (`routingOf`): never its value.
868
+ */
869
+ function secretViewOf(row, slot) {
858
870
  const binding = credentialBinding(row);
871
+ const routing = routingOf(binding, slot);
859
872
  return {
860
873
  name: row.name,
861
874
  type: row.type,
@@ -868,8 +881,7 @@ function secretViewOf(row) {
868
881
  ? {}
869
882
  : { url: binding.url },
870
883
  ...(binding.headerNames === undefined ? {} : { headerNames: binding.headerNames }),
871
- ...(binding.via === undefined ? {} : { via: binding.via }),
872
- ...(binding.toolPrefix === undefined ? {} : { toolPrefix: binding.toolPrefix }),
884
+ ...routing,
873
885
  ...(binding.identity === undefined ? {} : { identity: binding.identity }),
874
886
  createdAt: row.createdAt,
875
887
  ...(row.rotatedAt ? { rotatedAt: row.rotatedAt } : {}),
@@ -1074,11 +1086,15 @@ function rebind(name, slot, row, payload, binding) {
1074
1086
  return undefined;
1075
1087
  const sealed = payloadOf(auth);
1076
1088
  const routing = slot;
1077
- // The gateway's tool prefix is the vault's, not the slot's: it stays while the slot keeps `via`.
1089
+ // The secret's own via goes with it (`secret-routing`); else the fork's slot's, which its
1090
+ // sessions then follow. The gateway's tool prefix is the vault's; the slot's is read at use.
1091
+ const own = binding.via !== undefined && binding.viaFrom !== "slot" ? binding.via : undefined;
1092
+ const via = own ?? routing.via ?? null;
1093
+ const rebound = bindingOf(normalizeVaultUrl(routing.url), sealed, { ...routing, via, toolPrefix: binding.toolPrefix ?? null });
1078
1094
  return {
1079
1095
  type,
1080
1096
  payload: sealed,
1081
- binding: bindingOf(normalizeVaultUrl(routing.url), sealed, { ...routing, toolPrefix: binding.toolPrefix ?? null }),
1097
+ binding: own === undefined && rebound.via !== undefined ? { ...rebound, viaFrom: "slot" } : rebound,
1082
1098
  };
1083
1099
  }
1084
1100
  catch (error) {
@@ -1146,11 +1162,36 @@ export function slotHolds(slot, row) {
1146
1162
  * again. Without slots, every one.
1147
1163
  */
1148
1164
  async function liveFilter(t, environmentId) {
1149
- const slots = (await t.environment(environmentId))?.applied.secrets;
1165
+ return liveFilterOf(await appliedSlots(t, environmentId));
1166
+ }
1167
+ /** `liveFilter` under `slots`, the secret slots of the runtime an Agent applies. */
1168
+ function liveFilterOf(slots) {
1150
1169
  if (slots === undefined)
1151
1170
  return () => true;
1152
- return (row) => {
1153
- const slot = slots[row.name];
1154
- return slot !== undefined && slotHolds(slot, row);
1155
- };
1171
+ return (row) => slotOf(slots, row) !== undefined;
1172
+ }
1173
+ /** The secret slots of the runtime Agent `environmentId` applies; undefined when it declares none. */
1174
+ async function appliedSlots(t, environmentId) {
1175
+ return (await t.environment(environmentId))?.applied.secrets;
1176
+ }
1177
+ /** The slot of `slots` that the credential in `row` fills (`slotHolds`), if any. */
1178
+ function slotOf(slots, row) {
1179
+ const slot = slots?.[row.name];
1180
+ return slot !== undefined && slotHolds(slot, row) ? slot : undefined;
1181
+ }
1182
+ /**
1183
+ * Where a `bearer` or `headers` credential's requests go, and how the gateway there names tools,
1184
+ * at the time of the request (Host feature `secret-routing`), under `slot`, the slot it fills of
1185
+ * the version its Agent runs: `via` is the secret's own (written with it), else the slot's, else
1186
+ * none (the request goes to the URL); a `via` copied from the slot (`viaFrom: "slot"`) follows the
1187
+ * slot. `toolPrefix` is the slot's, else the secret's, and only with a `via`. The credential is
1188
+ * chosen by the URL either way.
1189
+ */
1190
+ export function routingOf(binding, slot) {
1191
+ const routed = slot?.type === "bearer" || slot?.type === "headers" ? slot : undefined;
1192
+ const via = binding.viaFrom === "slot" && routed !== undefined ? routed.via : (binding.via ?? routed?.via);
1193
+ if (via === undefined)
1194
+ return {};
1195
+ const toolPrefix = routed?.toolPrefix ?? binding.toolPrefix;
1196
+ return toolPrefix === undefined ? { via } : { via, toolPrefix };
1156
1197
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.37.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.39.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.37.0-beta";
3
+ export const RUNTIME_VERSION = "0.39.0-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.37.0-beta",
3
+ "version": "0.39.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.32.0-beta",
69
- "@nylorun/harness": "0.30.0-beta",
68
+ "@nylorun/core": "0.34.0-beta",
69
+ "@nylorun/harness": "0.31.0-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",