@nylorun/runtime 0.31.0-beta → 0.33.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 (37) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/api/components.d.ts +100 -0
  3. package/dist/api/components.js +4 -1
  4. package/dist/api/http/routes/agent-manifests.d.ts +2 -1
  5. package/dist/api/http/routes/agent-manifests.js +29 -2
  6. package/dist/api/http/routes/sessions.js +63 -2
  7. package/dist/api/openapi.js +4 -0
  8. package/dist/gates/handler.js +2 -2
  9. package/dist/gates/in-process.d.ts +4 -3
  10. package/dist/gates/in-process.js +11 -5
  11. package/dist/gates/tenant-vaults.d.ts +4 -3
  12. package/dist/gates/tenant-vaults.js +2 -2
  13. package/dist/harness-api/start.js +1 -1
  14. package/dist/host/main.js +2 -0
  15. package/dist/host/stack-config.d.ts +5 -0
  16. package/dist/host/stack-config.js +15 -0
  17. package/dist/management-openapi.json +90 -8
  18. package/dist/model/descriptor.d.ts +5 -4
  19. package/dist/model/descriptor.js +4 -3
  20. package/dist/model/pi-model.js +17 -1
  21. package/dist/openapi.json +880 -2
  22. package/dist/tenant/agent-manifests.d.ts +6 -1
  23. package/dist/tenant/agent-manifests.js +50 -8
  24. package/dist/tenant/agents.d.ts +4 -3
  25. package/dist/tenant/agents.js +26 -5
  26. package/dist/tenant/context.d.ts +6 -1
  27. package/dist/tenant/session-model.d.ts +16 -0
  28. package/dist/tenant/session-model.js +46 -0
  29. package/dist/vault/host-identity.d.ts +24 -0
  30. package/dist/vault/host-identity.js +35 -0
  31. package/dist/vault/host-model.d.ts +9 -5
  32. package/dist/vault/host-model.js +30 -19
  33. package/dist/vault/service.d.ts +18 -2
  34. package/dist/vault/service.js +52 -9
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +4 -4
@@ -1,4 +1,4 @@
1
- import { type AgentManifestChange, type AgentManifestPlan, type AgentManifestSource, type AgentManifestV8, type AgentManifestVersions, type AgentManifestView, type PutAgentFolderRequest, type PutAgentManifestRequest, type SaveAgentManifestResponse } from "@nylorun/core/contracts";
1
+ import { type AgentManifestChange, type AgentManifestPlan, type AgentManifestSource, type AgentManifestSourceDiff, type AgentManifestV8, type AgentManifestVersions, type AgentManifestView, type PutAgentFolderRequest, type PutAgentManifestRequest, type SaveAgentManifestResponse } from "@nylorun/core/contracts";
2
2
  import { type RecipeRequest } from "../sandbox/resolve.js";
3
3
  import type { AuthScope, TenantContext } from "./context.js";
4
4
  /** What a manifest's runtime section asks of an Agent's recipe, with the harness its loop names. */
@@ -47,6 +47,11 @@ export declare function readAgentManifestSourceFile(ctx: TenantContext, name: st
47
47
  body: ReadableStream<Uint8Array>;
48
48
  size: number;
49
49
  }>;
50
+ /**
51
+ * `GET /v1/agent-manifests/{name}/source/diff?from=&to=`: the files added, removed and changed
52
+ * (by SHA-256) from version `from`'s source to version `to`'s; a `404` when either was saved as JSON.
53
+ */
54
+ export declare function diffAgentManifestSources(ctx: TenantContext, name: string, from: number, to: number): Promise<AgentManifestSourceDiff>;
50
55
  /** `GET /v1/agent-manifests/{name}` (the latest) and `.../versions/{n}`. */
51
56
  export declare function getAgentManifest(ctx: TenantContext, name: string, version?: number): Promise<AgentManifestView>;
52
57
  /** `GET /v1/agent-manifests`: each manifest's latest version. */
@@ -27,9 +27,11 @@
27
27
  * compile is `422 folder_invalid`, every diagnostic in `details.diagnostics`), and saves the
28
28
  * manifest as above, with the folder kept as the version's source (`source`, its files recorded
29
29
  * as the version's definition files). A folder whose manifest is unchanged saves no new
30
- * version, so its source is not saved either.
30
+ * version, so its source is not saved either. What a folder may hold is the Tenant's Runtime
31
+ * limits `folderFiles`, `folderBytes` and `folderTextFileBytes` (track T7), which the compiler
32
+ * is given too; `source/diff` compares two versions' sources by path and hash.
31
33
  */
32
- import { compileAgentFolder, folderTextPaths, AGENT_FOLDER_LIMITS, } from "@nylorun/core/folder";
34
+ import { compileAgentFolder, folderTextPaths, } from "@nylorun/core/folder";
33
35
  import { definitionFilesOf, } from "@nylorun/core/contracts";
34
36
  import { hashManifest } from "@nylorun/core/compatibility";
35
37
  import { approvalRefusal, codeToolRefusal, harnessIdOf, loopManifestOf, runtimeOf, sectionGaps, } from "@nylorun/core/define";
@@ -356,10 +358,17 @@ async function saveManifest(ctx, name, manifest, scope, options, folder) {
356
358
  */
357
359
  export async function saveAgentFolder(ctx, name, body, scope, options = {}) {
358
360
  const entries = [...body.entries].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
359
- if (entries.length > AGENT_FOLDER_LIMITS.files)
360
- fail(413, `An agent folder holds at most ${AGENT_FOLDER_LIMITS.files} files; this one holds ${entries.length}`, {
361
+ const limits = folderLimitsOf(await ctx.store.tx((t) => readRuntimeLimits(t)));
362
+ if (entries.length > limits.files)
363
+ fail(413, `An agent folder holds at most ${limits.files} files (the Runtime limit folderFiles); this one holds ${entries.length}`, {
361
364
  code: "limit_exceeded",
362
- details: { limit: AGENT_FOLDER_LIMITS.files },
365
+ details: { limit: "folderFiles", value: limits.files },
366
+ });
367
+ const bytes = entries.reduce((sum, entry) => sum + entry.size, 0);
368
+ if (bytes > limits.totalBytes)
369
+ fail(413, `An agent folder holds at most ${limits.totalBytes} bytes (the Runtime limit folderBytes); this one holds ${bytes}`, {
370
+ code: "limit_exceeded",
371
+ details: { limit: "folderBytes", value: limits.totalBytes },
363
372
  });
364
373
  // Every file must be held: the version keeps them all as its source.
365
374
  const shas = [...new Set(entries.map((entry) => `sha256:${entry.sha256}`))].sort();
@@ -377,13 +386,13 @@ export async function saveAgentFolder(ctx, name, body, scope, options = {}) {
377
386
  const files = new Map();
378
387
  for (const path of folderTextPaths(entries)) {
379
388
  const entry = entries.find((item) => item.path === path);
380
- if (entry.size > AGENT_FOLDER_LIMITS.textFileBytes)
389
+ if (entry.size > limits.textFileBytes)
381
390
  continue;
382
391
  const bytes = await readDefinitionFile(ctx.blobs, `sha256:${entry.sha256}`);
383
392
  if (bytes)
384
393
  files.set(path, bytes);
385
394
  }
386
- const compiled = compileAgentFolder({ entries, files, name });
395
+ const compiled = compileAgentFolder({ entries, files, name, limits });
387
396
  if (!compiled.manifest) {
388
397
  const errors = compiled.diagnostics.filter((item) => item.severity === "error");
389
398
  fail(422, `Agent folder ${name} does not compile: ${errors.length} error(s), the first: ${located(errors[0])}`, {
@@ -394,7 +403,7 @@ export async function saveAgentFolder(ctx, name, body, scope, options = {}) {
394
403
  const source = {
395
404
  compiler: compiled.compiler,
396
405
  files: entries.length,
397
- bytes: entries.reduce((sum, entry) => sum + entry.size, 0),
406
+ bytes,
398
407
  entries: entries.map((entry) => ({ path: entry.path, size: entry.size, sha256: entry.sha256 })),
399
408
  };
400
409
  return saveManifest(ctx, name, compiled.manifest, scope, options, {
@@ -403,6 +412,10 @@ export async function saveAgentFolder(ctx, name, body, scope, options = {}) {
403
412
  compiler: compiled.compiler,
404
413
  });
405
414
  }
415
+ /** What a folder may hold: the Tenant's Runtime limits, as the compiler takes them. */
416
+ function folderLimitsOf(limits) {
417
+ return { files: limits.folderFiles, totalBytes: limits.folderBytes, textFileBytes: limits.folderTextFileBytes };
418
+ }
406
419
  /** A diagnostic as a person reads it: `file:line:column message`. */
407
420
  function located(diagnostic) {
408
421
  if (!diagnostic)
@@ -435,6 +448,35 @@ export async function readAgentManifestSourceFile(ctx, name, version, path) {
435
448
  fail(404, `The Runtime no longer holds ${path} of version ${version} of agent manifest ${name}.`, { code: "not_found" });
436
449
  return { body: body, size: entry.size };
437
450
  }
451
+ /**
452
+ * `GET /v1/agent-manifests/{name}/source/diff?from=&to=`: the files added, removed and changed
453
+ * (by SHA-256) from version `from`'s source to version `to`'s; a `404` when either was saved as JSON.
454
+ */
455
+ export async function diffAgentManifestSources(ctx, name, from, to) {
456
+ const before = new Map((await sourceOf(ctx, name, from)).entries.map((entry) => [entry.path, entry]));
457
+ const after = (await sourceOf(ctx, name, to)).entries;
458
+ const diff = { name, from, to, added: [], removed: [], changed: [], unchanged: 0 };
459
+ for (const entry of after) {
460
+ const was = before.get(entry.path);
461
+ before.delete(entry.path);
462
+ if (!was)
463
+ diff.added.push(entry);
464
+ else if (was.sha256 === entry.sha256)
465
+ diff.unchanged++;
466
+ else
467
+ diff.changed.push({
468
+ path: entry.path,
469
+ from: { size: was.size, sha256: was.sha256 },
470
+ to: { size: entry.size, sha256: entry.sha256 },
471
+ });
472
+ }
473
+ diff.removed.push(...before.values());
474
+ const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
475
+ diff.added.sort(byPath);
476
+ diff.removed.sort(byPath);
477
+ diff.changed.sort(byPath);
478
+ return diff;
479
+ }
438
480
  /** `GET /v1/agent-manifests/{name}` (the latest) and `.../versions/{n}`. */
439
481
  export async function getAgentManifest(ctx, name, version) {
440
482
  return ctx.store.tx(async (t) => {
@@ -17,10 +17,11 @@ export declare function liveEnvironment(t: Tx, id: string, options?: {
17
17
  * An Agent's state (protocol 16), from its desired state, its compute and its sessions, and
18
18
  * every state that holds with the session behind it (`attention`), highest first. A session waits
19
19
  * on a person while it is paused (an approval or an interaction) or uncertain, and the Agent while
20
- * a move waits for its required secrets (track T3); a session's last turn
21
- * failed while it is `failed`; a turn runs while it is `running`, `runnable` or `waiting`.
20
+ * a move waits for its required secrets (track T3), or a required secret of the version it runs
21
+ * has no value (`unsetSecrets`, track T7: a tool would fail when it is called); a session's last
22
+ * turn failed while it is `failed`; a turn runs while it is `running`, `runnable` or `waiting`.
22
23
  */
23
- export declare function stateOf(agent: Pick<Environment, "desiredState" | "pod"> & Partial<Pick<Environment, "targetVersion" | "appliedVersion" | "version" | "failedVersion">>, sessions: readonly Pick<Session, "id" | "status">[]): {
24
+ export declare function stateOf(agent: Pick<Environment, "desiredState" | "pod"> & Partial<Pick<Environment, "targetVersion" | "appliedVersion" | "version" | "failedVersion">>, sessions: readonly Pick<Session, "id" | "status">[], unsetSecrets?: readonly string[]): {
24
25
  state: AgentState;
25
26
  attention: AgentAttention[];
26
27
  };
@@ -22,7 +22,8 @@
22
22
  * the Agent's id).
23
23
  * - **State on read.** `state` is derived when it is read, the first rule that holds: `paused`
24
24
  * (desired `off`, track T6), `failing` (its compute failed or was lost, or a session's last
25
- * turn failed), `input_required` (a session waits on a person), `working` (a turn runs),
25
+ * turn failed), `input_required` (a session waits on a person, or a required secret is unset),
26
+ * `working` (a turn runs),
26
27
  * `sleeping`; `attention` names the sessions behind each that holds (`stateOf`).
27
28
  * - **Secret slots** (D64): when its manifest declares `secrets`, a secret write sends only the
28
29
  * value, the binding comes from the slot, and a name it does not declare is
@@ -110,16 +111,20 @@ const STATE_RANK = ["paused", "failing", "input_required", "working", "sleeping"
110
111
  * An Agent's state (protocol 16), from its desired state, its compute and its sessions, and
111
112
  * every state that holds with the session behind it (`attention`), highest first. A session waits
112
113
  * on a person while it is paused (an approval or an interaction) or uncertain, and the Agent while
113
- * a move waits for its required secrets (track T3); a session's last turn
114
- * failed while it is `failed`; a turn runs while it is `running`, `runnable` or `waiting`.
114
+ * a move waits for its required secrets (track T3), or a required secret of the version it runs
115
+ * has no value (`unsetSecrets`, track T7: a tool would fail when it is called); a session's last
116
+ * turn failed while it is `failed`; a turn runs while it is `running`, `runnable` or `waiting`.
115
117
  */
116
- export function stateOf(agent, sessions) {
118
+ export function stateOf(agent, sessions, unsetSecrets = []) {
117
119
  const attention = [];
118
120
  if (agent.desiredState === "off")
119
121
  attention.push({ state: "paused", reason: "desired off" });
120
122
  // A move that waits for a person to set its required secrets (track T3).
121
123
  if (agent.targetVersion !== undefined && agent.appliedVersion === agent.version)
122
124
  attention.push({ state: "input_required", reason: `secret_required: version ${agent.targetVersion} waits for its required secrets` });
125
+ // A required secret of the version it runs, unset (track T7).
126
+ for (const name of unsetSecrets)
127
+ attention.push({ state: "input_required", reason: `secret_unset: required secret ${name} unset` });
123
128
  const pod = agent.pod;
124
129
  if (pod && (pod.observed === "failed" || pod.observed === "lost") && pod.desired !== "deleted")
125
130
  attention.push({ state: "failing", reason: `compute ${pod.observed}${pod.reason ? `: ${pod.reason}` : ""}` });
@@ -152,7 +157,7 @@ async function viewOf(ctx, t, environment, owner) {
152
157
  : undefined;
153
158
  const pod = environment.pod;
154
159
  const visible = sessions.filter((session) => owner === undefined || session.ownerUserId === owner);
155
- const { state, attention } = stateOf(environment, visible);
160
+ const { state, attention } = stateOf(environment, visible, await unsetRequiredSecrets(t, environment));
156
161
  return {
157
162
  id: environment.id,
158
163
  manifest: manifestRefOf(environment),
@@ -230,6 +235,22 @@ async function slotsAhead(t, environment) {
230
235
  const target = await t.agentManifest(environment.manifestName, environment.targetVersion);
231
236
  return target ? runtimeOf(target.body).secrets : environment.applied.secrets;
232
237
  }
238
+ /**
239
+ * The required secret slots of the version `environment` runs (its applied runtime) that its vault
240
+ * holds no value for, by name: the Agent's attention (track T7), before a tool fails for want of
241
+ * one. A move waiting on the next version's secrets is its own attention item (`stateOf`).
242
+ */
243
+ async function unsetRequiredSecrets(t, environment) {
244
+ const required = Object.entries(environment.applied.secrets ?? {}).filter(([, slot]) => slot.required === true);
245
+ if (required.length === 0)
246
+ return [];
247
+ const vault = await t.vaultOfEnvironment(environment.id);
248
+ const rows = vault ? await t.credentialsForVault(vault.id) : [];
249
+ return required
250
+ .filter(([name, slot]) => !rows.some((row) => row.name === name && slotHolds(slot, row)))
251
+ .map(([name]) => name)
252
+ .sort();
253
+ }
233
254
  /** Each secret slot `environment` declares (`slotsAhead`), and whether its vault holds a value that fills it. */
234
255
  async function slotsOf(t, environment) {
235
256
  const declared = await slotsAhead(t, environment);
@@ -15,7 +15,7 @@
15
15
  * under ownership (§10.6); `abortLocal` aborts an advance running on this process.
16
16
  */
17
17
  import type { SessionHistory } from "./history.js";
18
- import type { IssuerScope, SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
18
+ import type { IssuerScope, SessionModelChoice, SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
19
19
  import type { DurableCheckpoint, FlowCheckpoint } from "@nylorun/harness/run";
20
20
  import type { AgentManifest, JsonValue, SandboxManifest } from "@nylorun/core/define";
21
21
  import type { OutboxRequest, SessionStore, Tx } from "../store/types.js";
@@ -141,6 +141,11 @@ export interface Session {
141
141
  * Tenant-wide ones beside the session's own. Absent until a message names a file.
142
142
  */
143
143
  fileArtifacts?: string[];
144
+ /**
145
+ * The session's own model (Host feature `session-model`): its turns' model calls use it in
146
+ * place of the Tenant's while its provider is connected. Absent: the Tenant's model.
147
+ */
148
+ model?: SessionModelChoice;
144
149
  }
145
150
  export type AuthScope = {
146
151
  kind: "application";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * A session's own model (Host feature `session-model`): one of a connected provider's models,
3
+ * which the session's turns use in place of the Tenant's. It is kept on the session
4
+ * (`Session.model`): the Model Gate reads it at each call (`HostModelVault.readHostModel`) and a
5
+ * turn's start describes it (`turn.start.model`). The Tenant's model, and every other session's,
6
+ * stay as they are. While its provider is disconnected the session uses the Tenant's model again.
7
+ */
8
+ import type { SessionModelChoice, SessionModelView } from "@nylorun/core/contracts";
9
+ import { type SessionAccess, type TenantContext } from "./context.js";
10
+ /** The model the session's next turn uses, and where it comes from. */
11
+ export declare function readSessionModel(ctx: TenantContext, id: string, access: SessionAccess | undefined): Promise<SessionModelView>;
12
+ /**
13
+ * Chooses the session's own model, or clears it (`choice` undefined) so its turns use the
14
+ * Tenant's. A turn calls one model: the choice changes only while no turn runs or waits.
15
+ */
16
+ export declare function chooseSessionModel(ctx: TenantContext, id: string, choice: SessionModelChoice | undefined, access: SessionAccess | undefined): Promise<SessionModelView>;
@@ -0,0 +1,46 @@
1
+ import { catalogModel, hostModelCatalog } from "../model/catalog.js";
2
+ import { hostCredentialRowFor, modelBinding } from "../vault/service.js";
3
+ import { takesMessages } from "./commands.js";
4
+ import { lockedSession, sessionOf } from "./context.js";
5
+ import { fail } from "./http.js";
6
+ /** The model the session's next turn uses, and where it comes from. */
7
+ export async function readSessionModel(ctx, id, access) {
8
+ const session = await ctx.store.tx((t) => sessionOf(t, id, access));
9
+ return ctx.vault.getSessionModel(session.model);
10
+ }
11
+ /**
12
+ * Chooses the session's own model, or clears it (`choice` undefined) so its turns use the
13
+ * Tenant's. A turn calls one model: the choice changes only while no turn runs or waits.
14
+ */
15
+ export async function chooseSessionModel(ctx, id, choice, access) {
16
+ const session = await ctx.store.tx(async (t) => {
17
+ const s = await lockedSession(t, id, access);
18
+ if (!takesMessages(s))
19
+ fail(409, "The session's turn is running or waits: choose its model when the turn ends", {
20
+ code: "session_busy",
21
+ });
22
+ if (choice)
23
+ await checkChoice(t, choice);
24
+ const { model: _model, ...rest } = s;
25
+ const next = choice ? { ...rest, model: { provider: choice.provider, model: choice.model } } : rest;
26
+ await t.put("sessions", id, next);
27
+ return next;
28
+ });
29
+ return ctx.vault.getSessionModel(session.model);
30
+ }
31
+ /** A choice names a connected provider and, when its catalog lists models, one of them. */
32
+ async function checkChoice(t, choice) {
33
+ if (!/^[a-zA-Z0-9._-]+$/.test(choice.provider))
34
+ fail(400, "Invalid provider id", { code: "invalid_request" });
35
+ const row = await hostCredentialRowFor(t, choice.provider);
36
+ if (!row)
37
+ fail(422, `Model provider ${choice.provider} is not connected: connect it in Model Settings first`, {
38
+ code: "request_rejected",
39
+ });
40
+ // A custom endpoint serves the models it names; the provider's own model is always one.
41
+ if (choice.provider === "custom" || modelBinding(row).model === choice.model)
42
+ return;
43
+ const listed = hostModelCatalog().providers.find((provider) => provider.id === choice.provider);
44
+ if (listed && listed.models.length > 0 && !catalogModel(choice.provider, choice.model))
45
+ fail(422, `Unknown model ${choice.model} for provider ${choice.provider}`, { code: "request_rejected" });
46
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The Host's identity for model calls (ADR-71): a model credential of type `host_identity` holds
3
+ * no secret, only the Google Cloud project and location. The model gate calls Agent Platform with
4
+ * the Host's Google Application Default Credentials, which Google's client library finds at each
5
+ * call (`GOOGLE_APPLICATION_CREDENTIALS`, gcloud's file, or the metadata server on Google Cloud).
6
+ * Nothing of them is stored, logged or passed to a sandbox.
7
+ *
8
+ * Off unless the Host's operator lends it, per provider, with `NYLORUN_MODEL_HOST_IDENTITY` on the
9
+ * process that runs the gates. Every Tenant on the Host may then use it, so a Host that serves
10
+ * several organizations must not set it.
11
+ */
12
+ /** The providers that can use the Host's identity. */
13
+ export declare const HOST_IDENTITY_PROVIDERS: readonly ["google-vertex"];
14
+ /**
15
+ * What pi-ai's Vertex AI client takes in place of an API key to use Application Default
16
+ * Credentials, with the project and location in the call's `env`.
17
+ */
18
+ export declare const VERTEX_ADC_MARKER = "gcp-vertex-credentials";
19
+ /** Sets the providers this process lends the Host's identity to (at startup). */
20
+ export declare function useHostIdentity(providers: readonly string[]): void;
21
+ /** Why a model of `provider` may not use the Host's identity here, or undefined when it may. */
22
+ export declare function hostIdentityRefused(provider: string): string | undefined;
23
+ /** The call's `env` for a `host_identity` credential: where Agent Platform runs it. */
24
+ export declare function hostIdentityEnv(project: string, location: string): Record<string, string>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The Host's identity for model calls (ADR-71): a model credential of type `host_identity` holds
3
+ * no secret, only the Google Cloud project and location. The model gate calls Agent Platform with
4
+ * the Host's Google Application Default Credentials, which Google's client library finds at each
5
+ * call (`GOOGLE_APPLICATION_CREDENTIALS`, gcloud's file, or the metadata server on Google Cloud).
6
+ * Nothing of them is stored, logged or passed to a sandbox.
7
+ *
8
+ * Off unless the Host's operator lends it, per provider, with `NYLORUN_MODEL_HOST_IDENTITY` on the
9
+ * process that runs the gates. Every Tenant on the Host may then use it, so a Host that serves
10
+ * several organizations must not set it.
11
+ */
12
+ /** The providers that can use the Host's identity. */
13
+ export const HOST_IDENTITY_PROVIDERS = ["google-vertex"];
14
+ /**
15
+ * What pi-ai's Vertex AI client takes in place of an API key to use Application Default
16
+ * Credentials, with the project and location in the call's `env`.
17
+ */
18
+ export const VERTEX_ADC_MARKER = "gcp-vertex-credentials";
19
+ let lent = [];
20
+ /** Sets the providers this process lends the Host's identity to (at startup). */
21
+ export function useHostIdentity(providers) {
22
+ lent = [...providers];
23
+ }
24
+ /** Why a model of `provider` may not use the Host's identity here, or undefined when it may. */
25
+ export function hostIdentityRefused(provider) {
26
+ if (!HOST_IDENTITY_PROVIDERS.includes(provider))
27
+ return `The Host's identity authenticates ${HOST_IDENTITY_PROVIDERS.join(", ")} only, not ${provider}.`;
28
+ if (!lent.includes(provider))
29
+ return `This Host doesn't lend its identity to ${provider}: its operator sets NYLORUN_MODEL_HOST_IDENTITY=${provider}.`;
30
+ return undefined;
31
+ }
32
+ /** The call's `env` for a `host_identity` credential: where Agent Platform runs it. */
33
+ export function hostIdentityEnv(project, location) {
34
+ return { GOOGLE_CLOUD_PROJECT: project, GOOGLE_CLOUD_LOCATION: location };
35
+ }
@@ -18,11 +18,15 @@ export declare class HostModelVault {
18
18
  private readonly kek;
19
19
  constructor(options: HostModelVaultOptions);
20
20
  /**
21
- * Reads the active host model secret, with the inputs the operator added to catalog models
22
- * (protocol 12). Decrypts after the read commits.
21
+ * Reads the host model secret a call uses, with the inputs the operator added to catalog
22
+ * models (protocol 12): the session's own model while its provider is connected (Host feature
23
+ * `session-model`), else the Tenant's active one. Decrypts after the read commits.
24
+ */
25
+ readHostModel(sessionId?: string): Promise<HostModelSecret | undefined>;
26
+ /**
27
+ * Replaces a host model's secret, keeping its binding: `provider`'s, the one the refreshed
28
+ * credential was read for, else the active one's.
23
29
  */
24
- readHostModel(): Promise<HostModelSecret | undefined>;
25
- /** Replaces the active host model's secret, keeping its binding. */
26
30
  updateHostCredential(credential: {
27
31
  type: "api_key" | "oauth";
28
32
  key?: string;
@@ -30,5 +34,5 @@ export declare class HostModelVault {
30
34
  refresh?: string;
31
35
  access?: string;
32
36
  expires?: number;
33
- }): Promise<void>;
37
+ }, provider?: string): Promise<void>;
34
38
  }
@@ -1,7 +1,7 @@
1
1
  import { readAddedInputs } from "../tenant/model-setting.js";
2
2
  import { encryptSecret } from "./crypto.js";
3
3
  import { VaultError } from "./error.js";
4
- import { activeHostCredentialRow, HOST_VAULT_ID, hostModelAad, modelBinding, readHostPayload, } from "./service.js";
4
+ import { activeHostCredentialRow, hostCredentialRowFor, hostCredentialRowOf, HOST_VAULT_ID, hostModelAad, modelBinding, readHostPayload, } from "./service.js";
5
5
  export class HostModelVault {
6
6
  store;
7
7
  kek;
@@ -10,28 +10,36 @@ export class HostModelVault {
10
10
  this.kek = options.kek;
11
11
  }
12
12
  /**
13
- * Reads the active host model secret, with the inputs the operator added to catalog models
14
- * (protocol 12). Decrypts after the read commits.
13
+ * Reads the host model secret a call uses, with the inputs the operator added to catalog
14
+ * models (protocol 12): the session's own model while its provider is connected (Host feature
15
+ * `session-model`), else the Tenant's active one. Decrypts after the read commits.
15
16
  */
16
- async readHostModel() {
17
- const { row, addedInputs } = await this.store.tx(async (t) => ({
18
- row: await activeHostCredentialRow(t),
19
- addedInputs: await readAddedInputs(t),
20
- }));
21
- if (!row)
17
+ async readHostModel(sessionId) {
18
+ const { found, addedInputs } = await this.store.tx(async (t) => {
19
+ const session = sessionId === undefined ? undefined : await t.get("sessions", sessionId);
20
+ return {
21
+ found: await hostCredentialRowOf(t, session?.model),
22
+ addedInputs: await readAddedInputs(t),
23
+ };
24
+ });
25
+ if (!found)
22
26
  return undefined;
23
- const binding = modelBinding(row);
24
- const payload = readHostPayload(this.kek(), row, binding);
27
+ const { row, binding } = found;
28
+ // The row's own binding seals its secret; a session's model only changes what is called.
29
+ const payload = readHostPayload(this.kek(), row, modelBinding(row));
25
30
  const credential = binding.authType === "oauth"
26
31
  ? { type: "oauth", ...payload.oauth }
27
32
  : binding.authType === "workload"
28
33
  ? // The workload token is read from its file at each call (#496), never stored.
29
34
  { type: "workload" }
30
- : {
31
- type: "api_key",
32
- key: payload.apiKey,
33
- ...(payload.env ? { env: payload.env } : {}),
34
- };
35
+ : binding.authType === "host_identity"
36
+ ? // The Host's identity is found at each call (ADR-71): only where the calls go is kept.
37
+ { type: "host_identity", ...(payload.env ? { env: payload.env } : {}) }
38
+ : {
39
+ type: "api_key",
40
+ key: payload.apiKey,
41
+ ...(payload.env ? { env: payload.env } : {}),
42
+ };
35
43
  return {
36
44
  provider: binding.provider,
37
45
  model: binding.model,
@@ -42,11 +50,14 @@ export class HostModelVault {
42
50
  credential,
43
51
  };
44
52
  }
45
- /** Replaces the active host model's secret, keeping its binding. */
46
- async updateHostCredential(credential) {
53
+ /**
54
+ * Replaces a host model's secret, keeping its binding: `provider`'s, the one the refreshed
55
+ * credential was read for, else the active one's.
56
+ */
57
+ async updateHostCredential(credential, provider) {
47
58
  const kek = this.kek();
48
59
  await this.store.tx(async (t) => {
49
- const row = await activeHostCredentialRow(t);
60
+ const row = provider === undefined ? await activeHostCredentialRow(t) : await hostCredentialRowFor(t, provider);
50
61
  if (!row)
51
62
  throw new VaultError(404, "Model provider is not configured");
52
63
  const binding = modelBinding(row);
@@ -1,4 +1,4 @@
1
- import type { CredentialCoverage, CredentialCoverageRequest, CredentialIdentity, EnvironmentSecretSlot, AgentSecretView, HostModelAuthType, HostModelProviderInfo, HostModelView, PutAgentSecretRequest, PutHostModelRequest, CustomModelSettings, ModelInput, SelectHostModelRequest } from "@nylorun/core/contracts";
1
+ import type { CredentialCoverage, CredentialCoverageRequest, CredentialIdentity, 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";
@@ -166,7 +166,13 @@ export declare class VaultService {
166
166
  * the credential of the Agent's vault bound to the URL. Reads no secret, writes no audit row.
167
167
  */
168
168
  coverage(manifest: AgentManifest | WorkflowManifest, request: CredentialCoverageRequest): Promise<CredentialCoverage>;
169
- getHostModel(): Promise<HostModelView>;
169
+ /**
170
+ * The Tenant's model, or the model a session's turns use (`choice`, its own, while its provider
171
+ * is connected).
172
+ */
173
+ getHostModel(choice?: SessionModelChoice): Promise<HostModelView>;
174
+ /** The model a session's next turn uses, and where it comes from (Host feature `session-model`). */
175
+ getSessionModel(choice?: SessionModelChoice): Promise<SessionModelView>;
170
176
  listHostProviders(): Promise<{
171
177
  providers: HostModelProviderInfo[];
172
178
  }>;
@@ -194,6 +200,16 @@ export declare function hostModelRows(t: Tx): Promise<VaultCredentialRow[]>;
194
200
  export declare function hostCredentialRowFor(t: Tx, provider: string): Promise<VaultCredentialRow | undefined>;
195
201
  /** The provider the host model selection names, or the only (or legacy) one configured. */
196
202
  export declare function activeProviderId(t: Tx): Promise<string | undefined>;
203
+ /**
204
+ * The host model credential row a session's calls use (ciphertext), in `t`, with the binding
205
+ * they call: its own model (`choice`) on its provider's row while that provider is connected,
206
+ * else the Tenant's active one. `row`'s own binding seals its secret (`readHostPayload`).
207
+ */
208
+ export declare function hostCredentialRowOf(t: Tx, choice: SessionModelChoice | undefined): Promise<{
209
+ row: VaultCredentialRow;
210
+ binding: ModelBinding;
211
+ source: "session" | "tenant";
212
+ } | undefined>;
197
213
  /** The active host model credential row (ciphertext), in `t`. */
198
214
  export declare function activeHostCredentialRow(t: Tx): Promise<VaultCredentialRow | undefined>;
199
215
  export declare function readHostPayload(kek: Buffer, row: VaultCredentialRow, binding: ModelBinding): HostSecretPayload;
@@ -39,7 +39,8 @@
39
39
  * coverage(manifest: AgentManifest | WorkflowManifest, request: CredentialCoverageRequest): Promise<CredentialCoverage>
40
40
  *
41
41
  * // Host model credential: each opens its own transaction.
42
- * getHostModel(): Promise<HostModelView>
42
+ * getHostModel(choice?: SessionModelChoice): Promise<HostModelView>
43
+ * getSessionModel(choice?: SessionModelChoice): Promise<SessionModelView>
43
44
  * listHostProviders(): Promise<{ providers: HostModelProviderInfo[] }>
44
45
  * putHostModel(body: PutHostModelRequest): Promise<HostModelView>
45
46
  * selectHostModel(body: SelectHostModelRequest): Promise<HostModelView>
@@ -88,6 +89,7 @@ import { hostModelCatalog, inputsOfHostModel } from "../model/catalog.js";
88
89
  import { inputsError } from "../model/capabilities.js";
89
90
  import { readAddedInputs } from "../tenant/model-setting.js";
90
91
  import { normalizeVaultUrl } from "./url.js";
92
+ import { hostIdentityEnv, hostIdentityRefused } from "./host-identity.js";
91
93
  export const HOST_VAULT_ID = "host";
92
94
  const HOST_MODEL_ID = "host-model";
93
95
  export class VaultService {
@@ -510,8 +512,29 @@ export class VaultService {
510
512
  });
511
513
  }
512
514
  // --- host model ----------------------------------------------------------------
513
- async getHostModel() {
514
- return this.store.tx((t) => this.hostModelView(t));
515
+ /**
516
+ * The Tenant's model, or the model a session's turns use (`choice`, its own, while its provider
517
+ * is connected).
518
+ */
519
+ async getHostModel(choice) {
520
+ return this.store.tx((t) => this.hostModelView(t, choice));
521
+ }
522
+ /** The model a session's next turn uses, and where it comes from (Host feature `session-model`). */
523
+ async getSessionModel(choice) {
524
+ return this.store.tx(async (t) => {
525
+ const found = await hostCredentialRowOf(t, choice);
526
+ const kept = choice ? { choice } : {};
527
+ if (!found)
528
+ return { configured: false, ...kept };
529
+ return {
530
+ configured: true,
531
+ source: found.source,
532
+ provider: found.binding.provider,
533
+ model: found.binding.model,
534
+ ...(found.binding.baseUrl ? { baseUrl: found.binding.baseUrl } : {}),
535
+ ...kept,
536
+ };
537
+ });
515
538
  }
516
539
  async listHostProviders() {
517
540
  return this.store.tx(async (t) => {
@@ -546,10 +569,16 @@ export class VaultService {
546
569
  this.validateHostModel(body);
547
570
  if (body.auth.type === "workload" && body.provider !== "custom")
548
571
  throw new VaultError(400, "The workload token authenticates a custom provider only.");
572
+ if (body.auth.type === "host_identity") {
573
+ const refused = hostIdentityRefused(body.provider);
574
+ if (refused)
575
+ throw new VaultError(400, refused);
576
+ }
549
577
  await this.ensureHostVault(t);
550
578
  const credentialId = hostModelCredentialId(body.provider);
551
579
  await this.deleteHostProviderRows(t, body.provider);
552
- // The workload token is read on every call and never stored (#496).
580
+ // The workload token is read on every call and never stored (#496); the Host's identity
581
+ // too (ADR-71), which keeps only where its calls go.
553
582
  const payload = body.auth.type === "api_key"
554
583
  ? {
555
584
  apiKey: body.auth.key,
@@ -557,7 +586,9 @@ export class VaultService {
557
586
  }
558
587
  : body.auth.type === "oauth"
559
588
  ? { oauth: body.auth }
560
- : {};
589
+ : body.auth.type === "host_identity"
590
+ ? { env: hostIdentityEnv(body.auth.project, body.auth.location) }
591
+ : {};
561
592
  await this.insertHostCredential(t, kek, {
562
593
  id: credentialId,
563
594
  provider: body.provider,
@@ -714,11 +745,11 @@ export class VaultService {
714
745
  ...sealed,
715
746
  });
716
747
  }
717
- async hostModelView(t) {
718
- const row = await activeHostCredentialRow(t);
719
- if (!row)
748
+ async hostModelView(t, choice) {
749
+ const found = await hostCredentialRowOf(t, choice);
750
+ if (!found)
720
751
  return { configured: false };
721
- const binding = modelBinding(row);
752
+ const { binding } = found;
722
753
  return {
723
754
  configured: true,
724
755
  provider: binding.provider,
@@ -904,6 +935,18 @@ export async function activeProviderId(t) {
904
935
  return modelBinding(legacy).provider;
905
936
  return modelBinding(rows[0]).provider;
906
937
  }
938
+ /**
939
+ * The host model credential row a session's calls use (ciphertext), in `t`, with the binding
940
+ * they call: its own model (`choice`) on its provider's row while that provider is connected,
941
+ * else the Tenant's active one. `row`'s own binding seals its secret (`readHostPayload`).
942
+ */
943
+ export async function hostCredentialRowOf(t, choice) {
944
+ const chosen = choice && (await hostCredentialRowFor(t, choice.provider));
945
+ if (chosen)
946
+ return { row: chosen, binding: { ...modelBinding(chosen), model: choice.model }, source: "session" };
947
+ const row = await activeHostCredentialRow(t);
948
+ return row && { row, binding: modelBinding(row), source: "tenant" };
949
+ }
907
950
  /** The active host model credential row (ciphertext), in `t`. */
908
951
  export async function activeHostCredentialRow(t) {
909
952
  const active = await activeProviderId(t);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.31.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.33.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.31.0-beta";
3
+ export const RUNTIME_VERSION = "0.33.0-beta";