@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
@@ -20,7 +20,13 @@ export declare class HostAuthError extends Error {
20
20
  }
21
21
  /** The Tenant's side of the exchange, as the Harness API listener calls it. */
22
22
  export interface HostAuthority {
23
- join(request: HostJoinRequest): Promise<HostJoinAnswer>;
23
+ /**
24
+ * `imageEngine`: the Runtime version an engine the workload image carries reported (ADR-99);
25
+ * absent for an engine copied in.
26
+ */
27
+ join(request: HostJoinRequest, from?: {
28
+ imageEngine?: string;
29
+ }): Promise<HostJoinAnswer>;
24
30
  renew(hostToken: string): Promise<HostJoinAnswer>;
25
31
  /** The claims of a current host's token, for a connection; refuses a stale one. */
26
32
  verify(hostToken: string): Promise<HostClaims>;
@@ -85,7 +85,7 @@ export function hostAuthority(ctx) {
85
85
  return verdict.claims;
86
86
  };
87
87
  return {
88
- async join(request) {
88
+ async join(request, from) {
89
89
  const { sandboxId, podUid, joinToken, volumeId } = request;
90
90
  if (!isAgentId(sandboxId) ||
91
91
  typeof podUid !== "string" ||
@@ -146,6 +146,10 @@ export function hostAuthority(ctx) {
146
146
  hostEpoch: epoch,
147
147
  podUid,
148
148
  ...(pod.volumeId === undefined && volumeId !== undefined ? { volumeId } : {}),
149
+ // The engine the image carries, against the digest this pod runs (ADR-99).
150
+ ...(from?.imageEngine === undefined
151
+ ? {}
152
+ : { engineImage: { version: from.imageEngine, ...(pod.imageDigest ? { digest: pod.imageDigest } : {}) } }),
149
153
  ...(pod.desired === "running" ? { observed: "running", reason: null } : {}),
150
154
  }, now);
151
155
  if (pod.podUid !== podUid) {
@@ -0,0 +1,18 @@
1
+ export { BUILTIN_ENGINE_ROOT, ENGINE_VERSION_PATTERN, IMAGE_ENGINE_HEADER } from "../../harness/image-engine.js";
2
+ /** The engine an image carries, as the pod's record keeps it: its version, seen on `digest`. */
3
+ export interface EngineImage {
4
+ readonly version: string;
5
+ readonly digest?: string;
6
+ }
7
+ /**
8
+ * Whether a pod of `image` (its pinned digest, or as asked) runs the engine the image carries:
9
+ * the Tenant lists its repository, and the engine recorded for that digest is this Runtime's
10
+ * version or not known yet.
11
+ */
12
+ export declare function runsBuiltinEngine(input: {
13
+ readonly image: string | undefined;
14
+ readonly imageDigest: string | undefined;
15
+ readonly engineImage: EngineImage | undefined;
16
+ readonly listed: readonly string[] | undefined;
17
+ readonly runtimeVersion?: string;
18
+ }): boolean;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * An engine built into the workload's image (ADR-99): the Runtime image's tini, node and bundled
3
+ * engine under `/opt/nylorun`, which the sandboxes service runs with `entrypoint: "builtin"` and
4
+ * nothing copied in.
5
+ *
6
+ * The Tenant lists the images that carry one (`engine.images`, by repository). Such an engine is
7
+ * whichever Runtime version the image was built from, so it reports that version when it joins
8
+ * (`IMAGE_ENGINE_HEADER`, sent only by an engine running from `/opt/nylorun`), and the pod's
9
+ * record keeps it beside the digest it ran (`engineImage`). A pod runs the image's engine while
10
+ * that version is the Runtime's own, or not known yet for the digest it runs (a first start, which
11
+ * the Harness API version check at `hello` still guards); otherwise the engine is copied in, so a
12
+ * Runtime upgrade never leaves a pod on an older engine for more than that first turn.
13
+ */
14
+ import { RUNTIME_VERSION } from "../../version.js";
15
+ import { repositoryOf } from "./lifecycle.js";
16
+ export { BUILTIN_ENGINE_ROOT, ENGINE_VERSION_PATTERN, IMAGE_ENGINE_HEADER } from "../../harness/image-engine.js";
17
+ /**
18
+ * Whether a pod of `image` (its pinned digest, or as asked) runs the engine the image carries:
19
+ * the Tenant lists its repository, and the engine recorded for that digest is this Runtime's
20
+ * version or not known yet.
21
+ */
22
+ export function runsBuiltinEngine(input) {
23
+ const image = input.imageDigest ?? input.image;
24
+ if (!image || !input.listed?.length)
25
+ return false;
26
+ const repository = repositoryOf(image);
27
+ if (!input.listed.some((entry) => repositoryOf(entry) === repository))
28
+ return false;
29
+ const known = input.engineImage !== undefined && input.imageDigest !== undefined && input.engineImage.digest === input.imageDigest
30
+ ? input.engineImage.version
31
+ : undefined;
32
+ return known === undefined || known === (input.runtimeVersion ?? RUNTIME_VERSION);
33
+ }
@@ -5,11 +5,12 @@ export interface PodSpec {
5
5
  /** The workload image; the service defaults it to `python:3.13-slim`. */
6
6
  readonly image?: string;
7
7
  /**
8
- * What the workload runs: `engine` (default), our engine copied from `harnessImage`, or
9
- * `image`, the image's own entrypoint, for a custom harness built into it (R4.4).
8
+ * What the workload runs: `engine` (default), our engine copied from `harnessImage`;
9
+ * `builtin`, our engine the image carries under `/opt/nylorun` (ADR-99); or `image`, the image's
10
+ * own entrypoint, for a custom harness built into it (R4.4).
10
11
  */
11
- readonly entrypoint?: "engine" | "image";
12
- /** The Runtime image the engine is copied from; not with `entrypoint: "image"`. */
12
+ readonly entrypoint?: "engine" | "builtin" | "image";
13
+ /** The Runtime image the engine is copied from; not with `entrypoint: "builtin"` or `"image"`. */
13
14
  readonly harnessImage?: string;
14
15
  /** Replaces the engine command (diagnostics). */
15
16
  readonly command?: readonly string[];
@@ -8,6 +8,7 @@ import { DEFAULT_HARNESS } from "@nylorun/core/contracts";
8
8
  import { idleMsOf, memoryMiBOf } from "../policy.js";
9
9
  import { podTuningEnv } from "../../tuning.js";
10
10
  import { opIdOf } from "./lifecycle.js";
11
+ import { runsBuiltinEngine } from "./builtin.js";
11
12
  /** The pod's grace period to stop when the Tenant sets none (D34). */
12
13
  export const DEFAULT_STOP_GRACE_SECONDS = 10;
13
14
  /** The volume's size when the sandbox asks for none. */
@@ -40,12 +41,24 @@ export function podSpecOf(input) {
40
41
  // Pinned by digest once a pod ran it (Q14).
41
42
  const image = pod.imageDigest ?? spec.image;
42
43
  const ours = (input.harness ?? DEFAULT_HARNESS) === DEFAULT_HARNESS;
44
+ // Our engine, built into an image the Tenant lists, while it is this Runtime's (ADR-99).
45
+ const builtin = ours &&
46
+ runsBuiltinEngine({
47
+ image: spec.image,
48
+ imageDigest: pod.imageDigest,
49
+ engineImage: pod.engineImage,
50
+ listed: config.engine?.images,
51
+ });
43
52
  return {
44
53
  opId: opIdOf(pod),
45
54
  mode: input.mode,
46
55
  ...(image ? { image } : {}),
47
56
  // A custom harness is its image: the pod runs the image's entrypoint, nothing copied in.
48
- ...(ours ? { harnessImage: input.harnessImage } : { entrypoint: "image" }),
57
+ ...(!ours
58
+ ? { entrypoint: "image" }
59
+ : builtin
60
+ ? { entrypoint: "builtin" }
61
+ : { harnessImage: input.harnessImage }),
49
62
  cpus: spec.resources?.cpus ?? 1,
50
63
  memoryMiB: memoryMiBOf(spec),
51
64
  storageGiB: storageGiBOf(spec),
@@ -0,0 +1 @@
1
+ ALTER TABLE "nylorun"."agents" ADD COLUMN "engine_image" json;
@@ -295,6 +295,13 @@
295
295
  "when": 1793448353003,
296
296
  "tag": "0041_turn_index_backfill",
297
297
  "breakpoints": true
298
+ },
299
+ {
300
+ "idx": 42,
301
+ "version": "7",
302
+ "when": 1793509192153,
303
+ "tag": "0042_worker_engine_image",
304
+ "breakpoints": true
298
305
  }
299
306
  ]
300
307
  }
@@ -1302,6 +1302,25 @@ export declare const agents: import("drizzle-orm/pg-core").PgTableWithColumns<{
1302
1302
  }, {}, {
1303
1303
  pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
1304
1304
  }>;
1305
+ engineImage: import("drizzle-orm/pg-core").PgColumn<{
1306
+ name: "engineImage";
1307
+ tableName: "agents";
1308
+ dataType: "custom";
1309
+ columnType: "PgCustomColumn";
1310
+ data: unknown;
1311
+ driverParam: unknown;
1312
+ notNull: false;
1313
+ hasDefault: false;
1314
+ isPrimaryKey: false;
1315
+ isAutoincrement: false;
1316
+ hasRuntimeDefault: false;
1317
+ enumValues: undefined;
1318
+ baseColumn: never;
1319
+ identity: undefined;
1320
+ generated: undefined;
1321
+ }, {}, {
1322
+ pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
1323
+ }>;
1305
1324
  diskOp: import("drizzle-orm/pg-core").PgColumn<{
1306
1325
  name: "diskOp";
1307
1326
  tableName: "agents";
@@ -171,6 +171,8 @@ export const agents = nylorun.table("agents", {
171
171
  volumeId: text(),
172
172
  /** The workload image by digest, from the first ready pod on (Q14): later pods run it. */
173
173
  imageDigest: textC(),
174
+ /** The engine the image carries (ADR-99): `{ version, digest? }`, from a join of its engine. */
175
+ engineImage: jsonText(),
174
176
  /**
175
177
  * The disk lease (R2d step 3): `{ kind, version?, startedAt, until }` while a snapshot, a
176
178
  * restore or a seed copies the disk; turns are refused until it ends or `until` passes.
@@ -283,6 +283,7 @@ function environmentOf(row) {
283
283
  ...(row.retiring !== null ? { retiring: row.retiring } : {}),
284
284
  ...(row.volumeId !== null ? { volumeId: row.volumeId } : {}),
285
285
  ...(row.imageDigest !== null ? { imageDigest: row.imageDigest } : {}),
286
+ ...(row.engineImage ? { engineImage: row.engineImage } : {}),
286
287
  },
287
288
  }
288
289
  : {}),
@@ -317,6 +318,7 @@ function podColumns(patch) {
317
318
  retiring: patch.retiring,
318
319
  volumeId: patch.volumeId,
319
320
  imageDigest: patch.imageDigest,
321
+ engineImage: patch.engineImage,
320
322
  };
321
323
  return Object.fromEntries(Object.entries(columns).filter(([, value]) => value !== undefined));
322
324
  }
@@ -1458,6 +1460,20 @@ class PostgresTx {
1458
1460
  .orderBy(desc(memoryVersions.id))
1459
1461
  .limit(options.limit);
1460
1462
  }
1463
+ async memoryWritesSince(agentId, after, sessionId) {
1464
+ this.check();
1465
+ const conditions = [eq(memoryVersions.agentId, agentId)];
1466
+ if (after !== undefined)
1467
+ conditions.push(gt(memoryVersions.id, after));
1468
+ const [row] = await this.db
1469
+ .select({
1470
+ newest: sql `max(${memoryVersions.id})`,
1471
+ others: sql `coalesce(bool_or(${memoryVersions.sessionId} is distinct from ${sessionId}), false)`,
1472
+ })
1473
+ .from(memoryVersions)
1474
+ .where(and(...conditions));
1475
+ return { ...(row?.newest ? { newest: row.newest } : {}), others: row?.others === true };
1476
+ }
1461
1477
  async memoryVersion(agentId, id) {
1462
1478
  this.check();
1463
1479
  const [row] = await this.db
@@ -260,6 +260,15 @@ export interface SandboxPodState {
260
260
  * Environment is ready (Q14): every later pod, after a stop, a relaunch or a reset, runs it.
261
261
  */
262
262
  imageDigest?: string;
263
+ /**
264
+ * The engine the workload image carries (ADR-99, `sandbox/pods/builtin.ts`): its Runtime
265
+ * version, as its engine reported at join, and the digest that pod ran. It counts only while
266
+ * that digest is `imageDigest`.
267
+ */
268
+ engineImage?: {
269
+ version: string;
270
+ digest?: string;
271
+ };
263
272
  }
264
273
  /** A change to a pod's columns: `null` clears an optional one. */
265
274
  export type SandboxPodPatch = {
@@ -794,6 +803,15 @@ export interface Tx {
794
803
  before?: string;
795
804
  limit: number;
796
805
  }): Promise<MemoryVersionRow[]>;
806
+ /**
807
+ * Of Agent `agentId`'s memory versions after version id `after` (every one without it): the
808
+ * newest's id, and whether any was written other than by session `sessionId` (another session,
809
+ * or the API).
810
+ */
811
+ memoryWritesSince(agentId: string, after: string | undefined, sessionId: string): Promise<{
812
+ newest?: string;
813
+ others: boolean;
814
+ }>;
797
815
  memoryVersion(agentId: string, id: string): Promise<MemoryVersionRow | undefined>;
798
816
  /** Copies Agent `from`'s memories to new Agent `to`, each at version 1, with no history (a fork). */
799
817
  copyMemories(from: string, to: string, now: string): Promise<number>;
@@ -21,8 +21,11 @@
21
21
  * - **A turn takes the Agent's version.** A turn's first segment re-pins its session to the
22
22
  * version the Agent runs when that changed since the session's last turn (`repinTurn`), before
23
23
  * anything of the turn has run: no effect is journaled for it yet. The turn keeps that version
24
- * through every later segment, approval and rollover. A turn whose message named a variant,
25
- * and a flow's sessions, keep the version they pinned.
24
+ * through every later segment, approval and rollover. What its message asked of the turn's
25
+ * manifest (`Session.turnRequest`) applies again to the new version: its `removeTools`, and its
26
+ * variant, validated again against the new version. A variant that is no variant of it keeps
27
+ * the turn on the version it pinned (`turn.version_kept`), so a variant never widens what the
28
+ * turn may do. A flow's sessions keep the version they pinned.
26
29
  *
27
30
  * Re-pinning at the first segment, not when the message is accepted, keeps a turn that waits for
28
31
  * its compute to start on a version the Agent has at that start.
@@ -64,9 +67,14 @@ export declare function applyPin(session: Session, pin: SessionPin): void;
64
67
  * active turn is about to run its first segment and nothing of the turn has run yet: the turn
65
68
  * then runs that version. Returns whether it changed the session (the caller saves it).
66
69
  *
67
- * Leaves the session as it is for a flow and a flow's linked session, a turn whose message named
68
- * a variant, a later segment, a turn with any journaled effect, and a version whose harness is
69
- * not the session's (an Agent's harness is fixed for its life, D73).
70
+ * The turn's manifest on the new version is what its message asked (`Session.turnRequest`):
71
+ * its variant, validated against the new version, without the tools its `removeTools` names.
72
+ *
73
+ * Leaves the session as it is for a flow and a flow's linked session, a later segment, a turn
74
+ * with any journaled effect, a version whose harness is not the session's (an Agent's harness is
75
+ * fixed for its life, D73), a turn on a variant with no request recorded (accepted by an older
76
+ * Runtime), and a variant that is no variant of the new version: then the turn runs on the
77
+ * version it pinned, and `turn.version_kept` says why.
70
78
  */
71
79
  export declare function repinTurn(t: Tx, session: Session): Promise<boolean>;
72
80
  /** What a move of `agent` to `target` would do, and the target's runtime resolved. */
@@ -1,6 +1,8 @@
1
1
  import { hashManifest } from "@nylorun/core/compatibility";
2
2
  import { loopManifestOf, runtimeOf } from "@nylorun/core/define";
3
3
  import { isWorkflowManifest } from "../core/flow-host.js";
4
+ import { turnManifestFor } from "../core/turn-manifest.js";
5
+ import { variantStore } from "./session.js";
4
6
  import { canonical } from "../store/canonical.js";
5
7
  import { resolveRecipe, sandboxSpecOf } from "../sandbox/resolve.js";
6
8
  import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
@@ -60,9 +62,14 @@ export function applyPin(session, pin) {
60
62
  * active turn is about to run its first segment and nothing of the turn has run yet: the turn
61
63
  * then runs that version. Returns whether it changed the session (the caller saves it).
62
64
  *
63
- * Leaves the session as it is for a flow and a flow's linked session, a turn whose message named
64
- * a variant, a later segment, a turn with any journaled effect, and a version whose harness is
65
- * not the session's (an Agent's harness is fixed for its life, D73).
65
+ * The turn's manifest on the new version is what its message asked (`Session.turnRequest`):
66
+ * its variant, validated against the new version, without the tools its `removeTools` names.
67
+ *
68
+ * Leaves the session as it is for a flow and a flow's linked session, a later segment, a turn
69
+ * with any journaled effect, a version whose harness is not the session's (an Agent's harness is
70
+ * fixed for its life, D73), a turn on a variant with no request recorded (accepted by an older
71
+ * Runtime), and a variant that is no variant of the new version: then the turn runs on the
72
+ * version it pinned, and `turn.version_kept` says why.
66
73
  */
67
74
  export async function repinTurn(t, session) {
68
75
  const id = session.environmentId;
@@ -73,8 +80,9 @@ export async function repinTurn(t, session) {
73
80
  return false;
74
81
  if (!checkpoint || checkpoint.segment !== 0 || checkpoint.turnId !== session.activeTurnId)
75
82
  return false;
76
- // A variant the message named (a turn-manifest variant) is validated against the old pin.
77
- if (checkpoint.manifestHash !== session.manifestHash)
83
+ const request = session.turnRequest?.turnId === session.activeTurnId ? session.turnRequest : undefined;
84
+ // A variant whose request is not recorded cannot be validated against another version.
85
+ if (checkpoint.manifestHash !== session.manifestHash && request === undefined)
78
86
  return false;
79
87
  const agent = await t.environment(id);
80
88
  if (!agent)
@@ -86,10 +94,26 @@ export async function repinTurn(t, session) {
86
94
  const { pin } = await pinOf(t, agent);
87
95
  if (pin.harness !== harnessOf(session))
88
96
  return false;
97
+ const turn = request
98
+ ? turnManifestFor({ pinned: pin.manifest, pinnedHash: pin.manifestHash, request })
99
+ : { ok: true, manifest: pin.manifest, hash: pin.manifestHash };
100
+ if (!turn.ok) {
101
+ const from = { ...session.definition };
102
+ const to = { name: agent.manifestName, version: agent.manifestVersion };
103
+ await t.event(session.id, session.activeTurnId, "turn.version_kept", {
104
+ reason: "variant_invalid",
105
+ from,
106
+ to,
107
+ message: `The turn manifest the message carried is no variant of ${to.name}@${to.version}, the version Agent ${agent.id} runs now, so this turn runs on ${from.name}@${from.version}. Send a variant of the new version, or name the tools the turn may not use with removeTools.`,
108
+ });
109
+ return false;
110
+ }
89
111
  const changed = pin.manifestHash !== session.manifestHash;
90
112
  applyPin(session, pin);
91
113
  session.environmentVersion = agent.version;
92
- session.checkpoint = { ...session.checkpoint, manifestHash: pin.manifestHash };
114
+ if (turn.hash !== pin.manifestHash)
115
+ variantStore(session).put(turn.hash, turn.manifest);
116
+ session.checkpoint = { ...session.checkpoint, manifestHash: turn.hash };
93
117
  // MCP discovery is per loop manifest: the next one is the new version's.
94
118
  if (changed) {
95
119
  delete session.mcpSnapshot;
@@ -56,13 +56,25 @@ export declare function planAgentPut(ctx: TenantContext, id: string, body: PutAg
56
56
  * they are set (track T3). Best effort: the write stands either way.
57
57
  */
58
58
  export declare function secretWritten(ctx: TenantContext, id: string): Promise<void>;
59
+ /**
60
+ * A secret write as the vault takes it (`Keys.putEnvironmentSecret`): the typed request, and
61
+ * whether its `via` is the slot's (`viaFromSlot`), so a session follows the slot of the version
62
+ * its Agent runs, rather than this installation's own `via` (Host feature `secret-routing`).
63
+ */
64
+ export interface SecretWrite {
65
+ readonly request: PutAgentSecretRequest;
66
+ readonly viaFromSlot: boolean;
67
+ }
59
68
  /**
60
69
  * The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
61
70
  * slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
62
71
  * `{ headers }` for a `headers` slot) with the binding from the slot; a full typed body is
63
- * accepted only when its binding is the slot's. Without slots, today's typed body.
72
+ * accepted only when its binding is the slot's. Without slots, today's typed body. Either way a
73
+ * `bearer` or `headers` slot's write may add this installation's routing (Host feature
74
+ * `secret-routing`): `via`, which wins over the slot's, and the gateway's `toolPrefix`, which
75
+ * gives way to the slot's (a different one is accepted, with a warning in the log).
64
76
  */
65
- export declare function secretRequestFor(ctx: TenantContext, id: string, name: string, raw: unknown): Promise<PutAgentSecretRequest>;
77
+ export declare function secretRequestFor(ctx: TenantContext, id: string, name: string, raw: unknown): Promise<SecretWrite>;
66
78
  /** What a create does for a fork (`POST .../fork`); `copied` is filled in by the create. */
67
79
  interface Fork {
68
80
  source: string;
@@ -560,15 +560,18 @@ export async function secretWritten(ctx, id) {
560
560
  * The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
561
561
  * slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
562
562
  * `{ headers }` for a `headers` slot) with the binding from the slot; a full typed body is
563
- * accepted only when its binding is the slot's. Without slots, today's typed body.
563
+ * accepted only when its binding is the slot's. Without slots, today's typed body. Either way a
564
+ * `bearer` or `headers` slot's write may add this installation's routing (Host feature
565
+ * `secret-routing`): `via`, which wins over the slot's, and the gateway's `toolPrefix`, which
566
+ * gives way to the slot's (a different one is accepted, with a warning in the log).
564
567
  */
565
568
  export async function secretRequestFor(ctx, id, name, raw) {
566
- const request = await parseSecretRequest(ctx, id, name, raw);
569
+ const write = await parseSecretRequest(ctx, id, name, raw);
567
570
  // Within the Tenant's Runtime limits `secretValueChars` and `secretHosts`, read for the write.
568
- const issues = secretRequestIssues(request, await ctx.store.tx((t) => readRuntimeLimits(t)));
571
+ const issues = secretRequestIssues(write.request, await ctx.store.tx((t) => readRuntimeLimits(t)));
569
572
  if (issues.length > 0)
570
573
  fail(400, issues.join(" "));
571
- return request;
574
+ return write;
572
575
  }
573
576
  async function parseSecretRequest(ctx, id, name, raw) {
574
577
  const { environment, slots } = await ctx.store.tx(async (t) => {
@@ -576,38 +579,73 @@ async function parseSecretRequest(ctx, id, name, raw) {
576
579
  return { environment, slots: environment && (await slotsAhead(t, environment)) };
577
580
  });
578
581
  if (!environment || slots === undefined)
579
- return PutAgentSecretRequestSchema.parse(raw);
582
+ return { request: PutAgentSecretRequestSchema.parse(raw), viaFromSlot: false };
580
583
  const named = `${environment.manifestName}@${environment.targetVersion ?? environment.manifestVersion}`;
581
584
  const slot = slots[name] ??
582
585
  fail(409, `Agent ${id}'s manifest (${named}) declares no secret ${name}: add the slot to the manifest's runtime section, then move the Agent to the new version.`, { code: "secret_not_in_manifest", details: { declared: Object.keys(slots).sort() } });
583
586
  const full = slotRequest(slot);
587
+ const slotVia = typeof full.via === "string" ? full.via : undefined;
588
+ const routed = slot.type === "bearer" || slot.type === "headers";
589
+ /** The write's prefix gives way to the slot's: said in the log when they differ. */
590
+ const prefixed = (toolPrefix) => {
591
+ if (routed && toolPrefix !== undefined && slot.toolPrefix !== undefined && toolPrefix !== slot.toolPrefix)
592
+ ctx.config.logger.warn("secret tool prefix gives way to the manifest's", {
593
+ environmentId: id,
594
+ secret: name,
595
+ manifest: named,
596
+ });
597
+ };
584
598
  if (raw !== null && typeof raw === "object" && "type" in raw) {
585
599
  const body = PutAgentSecretRequestSchema.parse(raw);
586
- // The gateway's tool prefix is this installation's to add (`gateway-credentials`).
587
- const { requestId: _, value: _v, headers: _h, toolPrefix: _p, ...binding } = body;
588
- if (canonical(binding) !== canonical(full))
600
+ // This installation's routing (`gateway-credentials`, `secret-routing`): its via and the
601
+ // gateway's tool prefix are its own to add.
602
+ const { requestId: _, value: _v, headers: _h, toolPrefix, via, ...binding } = body;
603
+ const { via: _s, ...slotBinding } = full;
604
+ if (canonical(binding) !== canonical(slotBinding))
589
605
  fail(409, `Secret ${name}'s type and binding come from Agent ${id}'s manifest: send only its value ({ "value": "…" }).`, {
590
606
  code: "secret_not_in_manifest",
591
607
  });
592
- return body;
608
+ prefixed(toolPrefix);
609
+ if (!routed || (via !== undefined && via !== slotVia))
610
+ return { request: body, viaFromSlot: false };
611
+ // No via of its own: the slot's, which a session reads from the version its Agent runs.
612
+ return {
613
+ request: PutAgentSecretRequestSchema.parse({ ...body, ...(slotVia === undefined ? {} : { via: slotVia }) }),
614
+ viaFromSlot: slotVia !== undefined,
615
+ };
593
616
  }
594
- const { requestId, value, headers, toolPrefix } = PutAgentSecretValueRequestSchema.parse(raw);
617
+ const { requestId, value, headers, via, toolPrefix } = PutAgentSecretValueRequestSchema.parse(raw);
618
+ if (!routed && (via !== undefined || toolPrefix !== undefined))
619
+ fail(400, `Secret ${name} is an ${slot.type}: via and toolPrefix are for a bearer or headers secret.`);
620
+ if (toolPrefix !== undefined && via === undefined && slotVia === undefined)
621
+ fail(400, `toolPrefix needs via: Secret ${name}'s slot names none, so send one with the value.`);
622
+ prefixed(toolPrefix);
595
623
  const withId = {
596
624
  ...(requestId === undefined ? {} : { requestId }),
597
625
  ...(toolPrefix === undefined ? {} : { toolPrefix }),
598
626
  };
627
+ // The write's via wins over the slot's (`secret-routing`); the URL still picks the credential.
628
+ const routing = via === undefined ? {} : { via };
629
+ const viaFromSlot = via === undefined && slotVia !== undefined;
599
630
  if (slot.type === "headers") {
600
631
  if (headers === undefined)
601
632
  fail(400, `Secret ${name} is a headers credential: send { "headers": { … } }.`);
602
- return PutAgentSecretRequestSchema.parse({ ...withId, ...full, headers });
633
+ return { request: PutAgentSecretRequestSchema.parse({ ...withId, ...full, ...routing, headers }), viaFromSlot };
603
634
  }
604
635
  if (value === undefined)
605
636
  fail(400, `Secret ${name} takes a value: send { "value": "…" }.`);
606
- return PutAgentSecretRequestSchema.parse({ ...withId, ...full, value });
637
+ return { request: PutAgentSecretRequestSchema.parse({ ...withId, ...full, ...routing, value }), viaFromSlot };
607
638
  }
608
- /** A slot's type and binding, as a secret write's fields (without the value). */
639
+ /**
640
+ * A slot's type and binding, as a secret write's fields (without the value). A slot's
641
+ * `toolPrefix` stays the slot's: a session reads it from the version its Agent runs.
642
+ */
609
643
  function slotRequest(slot) {
610
644
  const { required: _r, description: _d, ...binding } = slot;
645
+ if ("toolPrefix" in binding) {
646
+ const { toolPrefix: _p, ...rest } = binding;
647
+ return rest;
648
+ }
611
649
  return binding;
612
650
  }
613
651
  /**
@@ -24,7 +24,7 @@
24
24
  import { randomUUID } from "node:crypto";
25
25
  import { createDurableCheckpoint, createFlowCheckpoint, resumeFlowCheckpoint, } from "@nylorun/harness/run";
26
26
  import { commandKey, cancelQueuedEffects, flowInteractionOf, foreignInteractionConflict, isWorkflowManifest, planCancelCascade, wakeLinkedWorkflow, } from "../core/flow-host.js";
27
- import { resolveMessageManifest } from "../core/turn-manifest.js";
27
+ import { resolveMessageManifest, turnManifestFor, turnManifestRequestOf } from "../core/turn-manifest.js";
28
28
  import { canonical } from "../store/canonical.js";
29
29
  import { lockedSession, } from "./context.js";
30
30
  import { fail, HttpError } from "./http.js";
@@ -388,14 +388,21 @@ async function beginMessageTurn(t, s, id, messages) {
388
388
  input: first.input,
389
389
  });
390
390
  else {
391
- const resolved = resolveMessageManifest({
392
- pinned: s.manifest,
393
- pinnedHash: s.manifestHash,
394
- messageManifest: first.command.manifest,
395
- store: variantStore(s),
396
- });
391
+ const request = turnManifestRequestOf(first.command);
392
+ const resolved = turnManifestFor({ pinned: s.manifest, pinnedHash: s.manifestHash, request: request ?? {} });
397
393
  if (!resolved.ok)
398
394
  return fail(400, resolved.message);
395
+ if (resolved.hash !== s.manifestHash)
396
+ variantStore(s).put(resolved.hash, resolved.manifest);
397
+ // A turn's first segment applies the request again to the version it re-pins (`repinTurn`).
398
+ if (request)
399
+ s.turnRequest = {
400
+ turnId,
401
+ ...(resolved.variant === undefined ? {} : { manifest: resolved.variant }),
402
+ ...(request.removeTools === undefined ? {} : { removeTools: [...request.removeTools] }),
403
+ };
404
+ else
405
+ delete s.turnRequest;
399
406
  checkpoint = createDurableCheckpoint({
400
407
  manifest: resolved.manifest,
401
408
  sessionId: id,
@@ -456,7 +463,8 @@ export async function startQueuedTurn(ctx, t, s, id) {
456
463
  const queued = await t.queuedMessages(id);
457
464
  if (queued.length === 0)
458
465
  return undefined;
459
- const manifestOf = (row) => canonical(row.command.manifest ?? null);
466
+ // Messages that ask the same of their turn's manifest (a variant, `removeTools`) share a turn.
467
+ const manifestOf = (row) => canonical(turnManifestRequestOf(row.command) ?? null);
460
468
  const batch = isWorkflowManifest(s.manifest) ? [queued[0]] : [];
461
469
  if (batch.length === 0)
462
470
  for (const row of queued) {
@@ -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, SessionModelChoice, SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
18
+ import type { IssuerScope, SessionModelChoice, SessionModelRecord, 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";
@@ -63,6 +63,17 @@ export interface Session {
63
63
  loop?: Record<string, unknown>;
64
64
  /** Validated turn-manifest variants, keyed by hash (loops.md §4.2). */
65
65
  variants?: Record<string, AgentManifest>;
66
+ /**
67
+ * What the active turn's first message asked of its manifest: a variant (`manifest`) and the
68
+ * tools it may not use (`removeTools`, Host feature `remove-tools`). A turn's first segment that
69
+ * re-pins the session to its Agent's version applies it again to that version (`repinTurn`).
70
+ * Absent when the message asked for neither.
71
+ */
72
+ turnRequest?: {
73
+ turnId: string;
74
+ manifest?: AgentManifest;
75
+ removeTools?: string[];
76
+ };
66
77
  info?: any;
67
78
  status: string;
68
79
  activeTurnId: string | null;
@@ -147,6 +158,24 @@ export interface Session {
147
158
  * place of the Tenant's while its provider is connected. Absent: the Tenant's model.
148
159
  */
149
160
  model?: SessionModelChoice;
161
+ /**
162
+ * The Agent's memory as the session's last turn started (track T5): the newest memory version
163
+ * then (`seen`), and whether that turn's first segment asked the harness to pin the index again
164
+ * (`refresh`: someone else wrote the memory since the turn before). Absent before its first turn.
165
+ */
166
+ memoryIndex?: {
167
+ turnId: string;
168
+ seen?: string;
169
+ refresh: boolean;
170
+ };
171
+ /** Where `model` came from: `manifest` when its Agent's version named it (`harness.model`). Absent: chosen. */
172
+ modelSource?: "manifest";
173
+ /**
174
+ * The model the session's turns call and why (`SessionModelRecord`, `tenant/session-model.ts`):
175
+ * as the session opened or chose it, and at each segment's start since. Absent while the
176
+ * Tenant has no model, and on a session opened before it was kept.
177
+ */
178
+ modelRecord?: SessionModelRecord;
150
179
  }
151
180
  export type AuthScope = {
152
181
  kind: "application";