@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
@@ -378,7 +378,7 @@ export function agentRoutes(api) {
378
378
  path: "/v1/agents/{agentId}/secrets/{name}",
379
379
  tags: ["Agents"],
380
380
  summary: "Write an Agent's secret",
381
- description: "Creates or replaces one credential of the Agent's vault: an `environment_secret` (the variable `name` reads the sentinel `nylorun_managed` in the sandbox, and egress-gate sets the value as a header on requests to `allowedHosts`), an `environment_variable` (a plain variable), or a `bearer` token or `headers` map for the MCP servers and HTTP tools at `url`. The same name and type re-seals the value and binding; another type replaces it. A rotated value is used from the next request. A second credential for a host or a URL is `409 credential_conflict`. When the Agent's manifest declares secret slots (while it moves to another version, that version's), send only the value (`{ value }`, or `{ headers }` for a `headers` slot): the type and binding come from the slot, and a name it does not declare is `409 secret_not_in_manifest`.",
381
+ description: "Creates or replaces one credential of the Agent's vault: an `environment_secret` (the variable `name` reads the sentinel `nylorun_managed` in the sandbox, and egress-gate sets the value as a header on requests to `allowedHosts`), an `environment_variable` (a plain variable), or a `bearer` token or `headers` map for the MCP servers and HTTP tools at `url`. The same name and type re-seals the value and binding; another type replaces it. A rotated value is used from the next request. A second credential for a host or a URL is `409 credential_conflict`. When the Agent's manifest declares secret slots (while it moves to another version, that version's), send only the value (`{ value }`, or `{ headers }` for a `headers` slot): the type and binding come from the slot, and a name it does not declare is `409 secret_not_in_manifest`. A `bearer` or `headers` slot's write may add `via`, where this installation sends the credential's requests (it wins over the slot's `via`; the slot's `url` still picks the credential), and the gateway's `toolPrefix`, which needs a `via` and gives way to the slot's.",
382
382
  request: {
383
383
  params: secretPath,
384
384
  body: {
@@ -395,8 +395,8 @@ export function agentRoutes(api) {
395
395
  const scope = c.get("scope");
396
396
  assertReaches(scope, id);
397
397
  const name = c.req.param("name") ?? "";
398
- const body = await secretRequestFor(c.env.tenant, id, name, await readJson(c));
399
- const secret = await c.env.tenant.keys.putEnvironmentSecret(id, name, body, actorOf(scope));
398
+ const write = await secretRequestFor(c.env.tenant, id, name, await readJson(c));
399
+ const secret = await c.env.tenant.keys.putEnvironmentSecret(id, name, write.request, actorOf(scope), ...(write.viaFromSlot ? [{ viaFromSlot: true }] : []));
400
400
  // A move that waited for this secret goes on (track T3).
401
401
  await secretWritten(c.env.tenant, id);
402
402
  return jsonResponse(200, secret);
@@ -31,6 +31,29 @@ export declare function resolveMessageManifest(args: {
31
31
  readonly messageManifest?: unknown;
32
32
  readonly store: TurnManifestStore;
33
33
  }): TurnManifestOk | TurnManifestErr;
34
+ /** What a message asks of its turn's manifest: a variant, and the tools it may not use. */
35
+ export interface TurnManifestRequest {
36
+ readonly manifest?: unknown;
37
+ readonly removeTools?: readonly string[];
38
+ }
39
+ /**
40
+ * The manifest a turn runs on `pinned`: the message's variant when it named one (validated as a
41
+ * variant of `pinned`), else `pinned`, without the tools `removeTools` names (`withoutTools`; names
42
+ * `pinned` does not have are ignored). Stores nothing: the caller keeps a manifest whose hash is
43
+ * not the pin's (`variantStore`).
44
+ */
45
+ export declare function turnManifestFor(args: {
46
+ readonly pinned: AgentManifest;
47
+ readonly pinnedHash: string;
48
+ readonly request: TurnManifestRequest;
49
+ }): (TurnManifestOk & {
50
+ readonly variant?: AgentManifest;
51
+ }) | TurnManifestErr;
52
+ /** A message's request of its turn's manifest, or undefined when it asks for none. */
53
+ export declare function turnManifestRequestOf(command: {
54
+ readonly manifest?: unknown;
55
+ readonly removeTools?: readonly string[];
56
+ }): TurnManifestRequest | undefined;
34
57
  /**
35
58
  * At turn start: if state's hash differs from the turn's, set `state.manifestHash`
36
59
  * to the turn's when both are the pin or validated variants (`loops.md` §4.2).
@@ -1,6 +1,6 @@
1
1
  import { AgentManifestSchema, } from "@nylorun/core/contracts";
2
2
  import { hashManifest } from "@nylorun/core/compatibility";
3
- import { isVariantOf } from "@nylorun/core/define";
3
+ import { isVariantOf, withoutTools } from "@nylorun/core/define";
4
4
  /**
5
5
  * Parse `message.manifest`, require it to be a variant of the session pin, and store the
6
6
  * manifest the turn runs with, by its hash. Applies to that turn only (caller must not latch).
@@ -41,6 +41,41 @@ export function resolveMessageManifest(args) {
41
41
  store: args.store,
42
42
  });
43
43
  }
44
+ /**
45
+ * The manifest a turn runs on `pinned`: the message's variant when it named one (validated as a
46
+ * variant of `pinned`), else `pinned`, without the tools `removeTools` names (`withoutTools`; names
47
+ * `pinned` does not have are ignored). Stores nothing: the caller keeps a manifest whose hash is
48
+ * not the pin's (`variantStore`).
49
+ */
50
+ export function turnManifestFor(args) {
51
+ const scratch = new Map();
52
+ const resolved = resolveMessageManifest({
53
+ pinned: args.pinned,
54
+ pinnedHash: args.pinnedHash,
55
+ messageManifest: args.request.manifest,
56
+ store: { get: (hash) => scratch.get(hash), put: (hash, manifest) => void scratch.set(hash, manifest) },
57
+ });
58
+ if (!resolved.ok)
59
+ return resolved;
60
+ // The message's variant as validated, which a re-pin validates again against the new version.
61
+ const variant = args.request.manifest === undefined ? {} : { variant: resolved.manifest };
62
+ const manifest = args.request.removeTools?.length
63
+ ? withoutTools(resolved.manifest, args.request.removeTools)
64
+ : resolved.manifest;
65
+ if (manifest === resolved.manifest)
66
+ return { ...resolved, ...variant };
67
+ return { ok: true, manifest, hash: hashManifest(manifest), ...variant };
68
+ }
69
+ /** A message's request of its turn's manifest, or undefined when it asks for none. */
70
+ export function turnManifestRequestOf(command) {
71
+ const removeTools = command.removeTools?.length ? [...new Set(command.removeTools)] : undefined;
72
+ if (command.manifest === undefined && removeTools === undefined)
73
+ return undefined;
74
+ return {
75
+ ...(command.manifest === undefined ? {} : { manifest: command.manifest }),
76
+ ...(removeTools === undefined ? {} : { removeTools }),
77
+ };
78
+ }
44
79
  /**
45
80
  * At turn start: if state's hash differs from the turn's, set `state.manifestHash`
46
81
  * to the turn's when both are the pin or validated variants (`loops.md` §4.2).
@@ -11,6 +11,10 @@
11
11
  * manifest saved before R5 step 2 keeps its `approval: "always"` as an ask: no session loses
12
12
  * the approval it was opened with. A policy that cannot be read refuses every call.
13
13
  *
14
+ * A rule's `agent` matches the agent that calls: the session's root agent by its manifest's name,
15
+ * an agent it uses as a tool by its id, and that subagent's calls by the root's name too, so a rule
16
+ * for an agent covers the agents it uses as tools.
17
+ *
14
18
  * A rule's `tool` matches the tool's identity (protocol 15): an MCP tool's own name on its
15
19
  * server (`server` names the server), an HTTP tool's declared name, a flow stage's key. The name
16
20
  * a harness gave the model never matches. A stored rule whose `tool` has `__` in it (an MCP tool
@@ -37,6 +41,11 @@ export interface PolicyCall {
37
41
  readonly server?: string;
38
42
  /** The agent that calls it: its manifest's id (an agent manifest's name), never an Agent's id. */
39
43
  readonly agent: string;
44
+ /**
45
+ * The session's root agent (its loop manifest's id), when `agent` is an agent it uses as a
46
+ * tool: a rule whose `agent` names the root matches its subagents' calls too.
47
+ */
48
+ readonly root?: string;
40
49
  readonly input: unknown;
41
50
  /** The name the model called it by, for display only: no rule matches it. */
42
51
  readonly name?: string;
@@ -63,9 +72,10 @@ export declare function readToolPolicy(t: Pick<Tx, "getSetting">): Promise<ToolP
63
72
  export declare function toolCallDecision(t: Pick<Tx, "getSetting">, s: Session, request: HostEffect, kind: "mcp" | "http"): Promise<PolicyDecision>;
64
73
  /**
65
74
  * `request` as the policy reads it: by its target's identity (protocol 15), and by the agent that
66
- * calls it: an agent used as a tool by its id, the session's root agent by its loop manifest's id
67
- * (protocol 16: the agent manifest's name). Never the session's Agent id (`s.agentId`), which
68
- * protocol 16 made the Agent the session runs on (`users/ada/desk` for the manifest `desk`).
75
+ * calls it: an agent used as a tool by its id, with the session's root agent as its `root`, and
76
+ * the root agent by its loop manifest's id (protocol 16: the agent manifest's name). Never the
77
+ * session's Agent id (`s.agentId`), which protocol 16 made the Agent the session runs on
78
+ * (`users/ada/desk` for the manifest `desk`).
69
79
  */
70
80
  export declare function policyCallOf(s: Pick<Session, "manifest">, request: HostEffect, kind: "mcp" | "http"): PolicyCall;
71
81
  /** Decides `call` by `rules`; `pinnedAsk` adds the ask of a manifest saved before R5 step 2. */
@@ -11,6 +11,10 @@
11
11
  * manifest saved before R5 step 2 keeps its `approval: "always"` as an ask: no session loses
12
12
  * the approval it was opened with. A policy that cannot be read refuses every call.
13
13
  *
14
+ * A rule's `agent` matches the agent that calls: the session's root agent by its manifest's name,
15
+ * an agent it uses as a tool by its id, and that subagent's calls by the root's name too, so a rule
16
+ * for an agent covers the agents it uses as tools.
17
+ *
14
18
  * A rule's `tool` matches the tool's identity (protocol 15): an MCP tool's own name on its
15
19
  * server (`server` names the server), an HTTP tool's declared name, a flow stage's key. The name
16
20
  * a harness gave the model never matches. A stored rule whose `tool` has `__` in it (an MCP tool
@@ -70,9 +74,10 @@ export async function toolCallDecision(t, s, request, kind) {
70
74
  }
71
75
  /**
72
76
  * `request` as the policy reads it: by its target's identity (protocol 15), and by the agent that
73
- * calls it: an agent used as a tool by its id, the session's root agent by its loop manifest's id
74
- * (protocol 16: the agent manifest's name). Never the session's Agent id (`s.agentId`), which
75
- * protocol 16 made the Agent the session runs on (`users/ada/desk` for the manifest `desk`).
77
+ * calls it: an agent used as a tool by its id, with the session's root agent as its `root`, and
78
+ * the root agent by its loop manifest's id (protocol 16: the agent manifest's name). Never the
79
+ * session's Agent id (`s.agentId`), which protocol 16 made the Agent the session runs on
80
+ * (`users/ada/desk` for the manifest `desk`).
76
81
  */
77
82
  export function policyCallOf(s, request, kind) {
78
83
  const target = request.target;
@@ -83,6 +88,7 @@ export function policyCallOf(s, request, kind) {
83
88
  : (request.key ?? request.effectId),
84
89
  ...(target?.kind === "mcp" ? { server: target.server } : {}),
85
90
  agent: request.agent?.id ?? s.manifest.id,
91
+ ...(request.agent === undefined ? {} : { root: s.manifest.id }),
86
92
  input: request.input,
87
93
  ...(request.toolName === undefined ? {} : { name: request.toolName }),
88
94
  };
@@ -125,13 +131,20 @@ function matches(rule, call) {
125
131
  return false;
126
132
  if (rule.server !== undefined && (call.server === undefined || !glob(rule.server).test(call.server)))
127
133
  return false;
128
- if (rule.agent !== undefined && !glob(rule.agent).test(call.agent))
134
+ if (rule.agent !== undefined && !agentMatches(glob(rule.agent), call))
129
135
  return false;
130
136
  for (const [path, condition] of Object.entries(rule.when ?? {}))
131
137
  if (!holds(condition, fieldOf(call.input, path)))
132
138
  return false;
133
139
  return true;
134
140
  }
141
+ /**
142
+ * A rule's `agent` matches the agent that calls (an agent used as a tool by its id), and the
143
+ * session's root agent for its subagents' calls: a rule for the agent covers the agents it uses.
144
+ */
145
+ function agentMatches(pattern, call) {
146
+ return pattern.test(call.agent) || (call.root !== undefined && pattern.test(call.root));
147
+ }
135
148
  /** A pattern where `*` is any run of characters, matched whole. */
136
149
  function glob(pattern) {
137
150
  return new RegExp(`^${pattern.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
@@ -21,8 +21,10 @@ export declare function harnessExecutors(options: HarnessExecutorsOptions): Harn
21
21
  /**
22
22
  * Readies the run's MCP servers (§10.5 step 3): a run without MCP facts (`turn.start.mcp`, the
23
23
  * session's first) discovers their tools and records the snapshot (the first recorded wins);
24
- * later ones reconnect the snapshot's servers and record what failed. Answers the facts the run
25
- * goes on with. Nothing for a session without MCP servers.
24
+ * later ones reconnect the snapshot's servers and record what failed. A turn's first segment also
25
+ * discovers again each declared server the snapshot lacks (one that failed or was refused before),
26
+ * and records the ones that answer now: they join the snapshot, and the turn offers their tools.
27
+ * Answers the facts the run goes on with. Nothing for a session without MCP servers.
26
28
  */
27
29
  export declare function prepareMcp(pool: McpPool, run: HarnessRun, record: McpRecorder): Promise<PreparedRun | undefined>;
28
30
  /** A run's routing, as the call helpers take it. Its sandbox events are claimed for the run. */
@@ -91,8 +91,10 @@ async function withSandbox(run, use) {
91
91
  /**
92
92
  * Readies the run's MCP servers (§10.5 step 3): a run without MCP facts (`turn.start.mcp`, the
93
93
  * session's first) discovers their tools and records the snapshot (the first recorded wins);
94
- * later ones reconnect the snapshot's servers and record what failed. Answers the facts the run
95
- * goes on with. Nothing for a session without MCP servers.
94
+ * later ones reconnect the snapshot's servers and record what failed. A turn's first segment also
95
+ * discovers again each declared server the snapshot lacks (one that failed or was refused before),
96
+ * and records the ones that answer now: they join the snapshot, and the turn offers their tools.
97
+ * Answers the facts the run goes on with. Nothing for a session without MCP servers.
96
98
  */
97
99
  export async function prepareMcp(pool, run, record) {
98
100
  const routing = routingOf(run);
@@ -114,9 +116,29 @@ export async function prepareMcp(pool, run, record) {
114
116
  }
115
117
  else {
116
118
  const diagnostics = await pool.reconnect({ sessionId, manifest, servers: facts.servers, signal: run.signal });
117
- if (diagnostics.length === 0)
119
+ // Not mid-turn: a turn's tools stay what its first segment offered.
120
+ const first = run.start.checkpoint?.segment === 0;
121
+ const known = new Set(facts.servers.map((server) => JSON.stringify([server.agentId ?? null, server.name])));
122
+ const missing = first
123
+ ? serversOf(manifest).filter((item) => !known.has(JSON.stringify([item.agentId ?? null, item.server.name])))
124
+ : [];
125
+ const found = missing.length === 0
126
+ ? undefined
127
+ : await pool.discover({
128
+ sessionId,
129
+ manifest,
130
+ manifestHash: hashManifest(manifest),
131
+ maxInstructionBytes: run.start.limits.recordEntryBytes,
132
+ signal: run.signal,
133
+ servers: missing,
134
+ });
135
+ const all = [...diagnostics, ...(found?.diagnostics ?? [])];
136
+ if (all.length === 0)
118
137
  return undefined;
119
- answer = await record({ diagnostics });
138
+ answer = await record({
139
+ ...(found && found.snapshot.servers.length > 0 ? { snapshot: found.snapshot } : {}),
140
+ diagnostics: all,
141
+ });
120
142
  }
121
143
  return answer.mcp === undefined ? undefined : { mcp: answer.mcp };
122
144
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * An engine the workload image carries (ADR-99): the Runtime image's tini, node and bundled engine
3
+ * under `/opt/nylorun`, run by the sandboxes service with `entrypoint: "builtin"`. Such an engine is
4
+ * the Runtime version the image was built from, so it says which at `host/join`; the Runtime decides
5
+ * from it whether the next pod runs it or copies its own in (`sandbox/pods/builtin.ts`).
6
+ */
7
+ /** Where an image keeps the engine it carries. */
8
+ export declare const BUILTIN_ENGINE_ROOT = "/opt/nylorun";
9
+ /** Sent at `host/join` only by an engine running from `BUILTIN_ENGINE_ROOT`: its Runtime version. */
10
+ export declare const IMAGE_ENGINE_HEADER = "nylorun-image-engine";
11
+ /** A Runtime version, as the header carries it. */
12
+ export declare const ENGINE_VERSION_PATTERN: RegExp;
13
+ /** Whether code at `moduleUrl` runs from an image's engine (the engine passes its own URL). */
14
+ export declare function isBuiltinEngine(moduleUrl: string): boolean;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * An engine the workload image carries (ADR-99): the Runtime image's tini, node and bundled engine
3
+ * under `/opt/nylorun`, run by the sandboxes service with `entrypoint: "builtin"`. Such an engine is
4
+ * the Runtime version the image was built from, so it says which at `host/join`; the Runtime decides
5
+ * from it whether the next pod runs it or copies its own in (`sandbox/pods/builtin.ts`).
6
+ */
7
+ /** Where an image keeps the engine it carries. */
8
+ export const BUILTIN_ENGINE_ROOT = "/opt/nylorun";
9
+ /** Sent at `host/join` only by an engine running from `BUILTIN_ENGINE_ROOT`: its Runtime version. */
10
+ export const IMAGE_ENGINE_HEADER = "nylorun-image-engine";
11
+ /** A Runtime version, as the header carries it. */
12
+ export const ENGINE_VERSION_PATTERN = /^[0-9A-Za-z.+-]{1,64}$/;
13
+ /** Whether code at `moduleUrl` runs from an image's engine (the engine passes its own URL). */
14
+ export function isBuiltinEngine(moduleUrl) {
15
+ return moduleUrl.startsWith(`file://${BUILTIN_ENGINE_ROOT}/`);
16
+ }
@@ -59,4 +59,6 @@ export declare function podHost(config: PodHostConfig, logger: {
59
59
  volumeFile?: string;
60
60
  /** Where the engine writes the trust bundle: the public roots and the egress CA (R2c). */
61
61
  caBundleFile?: string;
62
+ /** Whether this engine runs from the workload image (default: from where its code is). */
63
+ imageEngine?: boolean;
62
64
  }): PodHost;
@@ -16,6 +16,8 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
16
16
  import { rootCertificates } from "node:tls";
17
17
  import { dirname } from "node:path";
18
18
  import { hostTuning } from "../tuning.js";
19
+ import { RUNTIME_VERSION } from "../version.js";
20
+ import { IMAGE_ENGINE_HEADER, isBuiltinEngine } from "./image-engine.js";
19
21
  /** The variables CLIs read their trust bundle from: OpenSSL, Node, Python, curl, git, AWS. */
20
22
  const TRUST_VARIABLES = [
21
23
  "SSL_CERT_FILE",
@@ -29,6 +31,7 @@ class Refused extends Error {
29
31
  }
30
32
  export function podHost(config, logger, options = {}) {
31
33
  const call = options.fetch ?? fetch;
34
+ const imageEngine = options.imageEngine ?? isBuiltinEngine(import.meta.url);
32
35
  let held;
33
36
  let setup;
34
37
  let seeded = false;
@@ -41,6 +44,8 @@ export function podHost(config, logger, options = {}) {
41
44
  headers: {
42
45
  ...(init.body === undefined ? {} : { "content-type": "application/json" }),
43
46
  ...(init.bearer ? { authorization: `Bearer ${init.bearer}` } : {}),
47
+ // An engine the workload image carries says which Runtime it came from (ADR-99).
48
+ ...(path === "join" && imageEngine ? { [IMAGE_ENGINE_HEADER]: RUNTIME_VERSION } : {}),
44
49
  },
45
50
  ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
46
51
  signal: AbortSignal.timeout(hostTuning().harness.podJoinTimeoutMs),
@@ -195,7 +195,8 @@ export async function recordIntent(scope, effect, requestHash) {
195
195
  * - `mcp`: the tool `{server, tool}` of an MCP server its agent declares, as the session
196
196
  * discovered it (`tool.unknown` otherwise), left enabled by the turn's manifest (R2b C9,
197
197
  * `tool.disabled`);
198
- * - `http`: an HTTP tool its agent declares under that name (R2 M3), which crosses the Tool Gate;
198
+ * - `http`: an HTTP tool its agent declares under that name in the turn's manifest (R2 M3), which
199
+ * crosses the Tool Gate;
199
200
  * - `sandbox`: one of the sandbox operations, in a session with a sandbox;
200
201
  * - `harness`: a tool the harness runs in its own process; its egress gate is its only control.
201
202
  * - `runtime`: served by `recordIntent` itself, before this (`schedule_wake`, track T4).
@@ -222,7 +223,8 @@ function refusal(s, request) {
222
223
  return undefined;
223
224
  }
224
225
  case "http":
225
- return isHttpToolCall(s.manifest, request) ? undefined : unrunnableTool(request);
226
+ // Declared in the turn's manifest: a variant or `removeTools` may have removed it.
227
+ return isHttpToolCall(turnManifestOf(s), request) ? undefined : unrunnableTool(request);
226
228
  case "sandbox":
227
229
  return sessionSandboxSpec(s) !== undefined &&
228
230
  request.toolName !== undefined &&
@@ -266,9 +266,11 @@ export function createHarnessApiServer(ctx, options = {}) {
266
266
  /**
267
267
  * What a harness found readying the run's MCP servers: the first snapshot recorded for the
268
268
  * session wins, pinned as it is within the Tenant's Runtime limit `mcpSnapshotBytes` (a server
269
- * past it is left out, with a diagnostic: `fitSnapshot`), with `mcp.discovered` in its log;
270
- * diagnostics replace those of the same server. Answers the session's facts as the run's turn
271
- * sees them (protocol 15).
269
+ * past it is left out, with a diagnostic: `fitSnapshot`), with `mcp.discovered` in its log.
270
+ * A later snapshot adds only servers the session's lacks (servers that failed at its first
271
+ * discovery and answer now, `prepareMcp`), within the limit, with `mcp.discovered` naming them
272
+ * (`rejoined`). Diagnostics replace those of the same server. Answers the session's facts as the
273
+ * run's turn sees them (protocol 15).
272
274
  */
273
275
  const recordMcp = async (run, params) => {
274
276
  const snapshot = params.snapshot;
@@ -286,17 +288,39 @@ export function createHarnessApiServer(ctx, options = {}) {
286
288
  await t.put("sessions", current.id, current);
287
289
  await t.event(current.id, run.grant.turnId, "mcp.discovered", mcpDiscovered(current.mcpSnapshot, diagnostics, current.manifest), run.connection ? writerOf(run.connection, run.grant.runId) : undefined);
288
290
  }
289
- else if (diagnostics.length > 0) {
290
- const prior = [...(current.mcpDiagnostics ?? [])];
291
- for (const item of diagnostics) {
292
- const index = prior.findIndex((existing) => (existing.agentId ?? null) === (item.agentId ?? null) && existing.serverName === item.serverName);
293
- if (index >= 0)
294
- prior[index] = item;
295
- else
296
- prior.push(item);
291
+ else {
292
+ const key = (agentId, name) => JSON.stringify([agentId ?? null, name]);
293
+ // Servers the session's snapshot lacks, which answered now: they join it.
294
+ let added = new Set();
295
+ if (snapshot && current.mcpSnapshot) {
296
+ const pinned = current.mcpSnapshot.servers;
297
+ const has = new Set(pinned.map((server) => key(server.agentId, server.name)));
298
+ const fresh = snapshot.servers.filter((server) => !has.has(key(server.agentId, server.name)));
299
+ if (fresh.length > 0) {
300
+ const fitted = fitSnapshot({ ...current.mcpSnapshot, servers: [...pinned, ...fresh] }, diagnostics, (await readRuntimeLimits(t)).mcpSnapshotBytes, pinned.length);
301
+ diagnostics = fitted.diagnostics;
302
+ current.mcpSnapshot = fitted.snapshot;
303
+ added = new Set(fitted.snapshot.servers.slice(pinned.length).map((server) => key(server.agentId, server.name)));
304
+ }
297
305
  }
298
- current.mcpDiagnostics = prior;
299
- await t.put("sessions", current.id, current);
306
+ if (diagnostics.length > 0) {
307
+ const prior = [...(current.mcpDiagnostics ?? [])];
308
+ for (const item of diagnostics) {
309
+ const index = prior.findIndex((existing) => key(existing.agentId, existing.serverName) === key(item.agentId, item.serverName));
310
+ if (index >= 0)
311
+ prior[index] = item;
312
+ else
313
+ prior.push(item);
314
+ }
315
+ current.mcpDiagnostics = prior;
316
+ }
317
+ if (diagnostics.length > 0 || added.size > 0)
318
+ await t.put("sessions", current.id, current);
319
+ if (added.size > 0 && current.mcpSnapshot)
320
+ await t.event(current.id, run.grant.turnId, "mcp.discovered", {
321
+ ...mcpDiscovered(current.mcpSnapshot, diagnostics.filter((item) => added.has(key(item.agentId, item.serverName))), current.manifest),
322
+ rejoined: true,
323
+ }, run.connection ? writerOf(run.connection, run.grant.runId) : undefined);
300
324
  }
301
325
  // The turn's manifest: a variant may have disabled tools.
302
326
  return current.mcpSnapshot ? { mcp: mcpFactsOf(current.mcpSnapshot, turnManifestOf(current)) } : {};
@@ -18,6 +18,7 @@ import { sandboxWorkspaceOf } from "../sandbox/share.js";
18
18
  import { ownedTx } from "../store/ownership.js";
19
19
  import { harnessOf } from "../tenant/context.js";
20
20
  import { repinTurn } from "../tenant/agent-versions.js";
21
+ import { noteSessionModel, sessionModelRecordOf } from "../tenant/session-model.js";
21
22
  import { HARNESS_ID } from "../sandbox/placement.js";
22
23
  import { checkParity, foldTranscriptWithCursor, leanState, transcriptOf, transcriptUpdates, withTranscript, } from "../tenant/history.js";
23
24
  import { usesFixtureModel } from "../tenant/model-setting.js";
@@ -45,6 +46,11 @@ options = {}) {
45
46
  // A turn's first segment runs the version its Agent runs now (T3, D73).
46
47
  if (options.harness && (await repinTurn(t, current)))
47
48
  await t.put("sessions", id, current);
49
+ // The model the segment's calls use, and why: `session.model` when it changed since.
50
+ if (options.harness &&
51
+ !isWorkflowManifest(current.manifest) &&
52
+ (await noteSessionModel(t, current, await sessionModelRecordOf(t, current))))
53
+ await t.put("sessions", id, current);
48
54
  // A transcript left on our engine's state by an older Runtime is recorded once; another
49
55
  // harness's state is its own (R4.2).
50
56
  const limits = await readRuntimeLimits(t);
@@ -70,7 +76,10 @@ options = {}) {
70
76
  ...(current.sandbox === undefined ? {} : { spec: current.sandbox }),
71
77
  };
72
78
  const turnId = current.activeTurnId;
79
+ const noted = current.memoryIndex;
73
80
  const memory = isWorkflowManifest(current.manifest) ? undefined : await turnMemoryOf(t, current, limits);
81
+ if (current.memoryIndex !== noted)
82
+ await t.put("sessions", id, current);
74
83
  return {
75
84
  current,
76
85
  fixtureModel,
@@ -170,19 +179,48 @@ export function buildTurnStart(ctx, segment) {
170
179
  /**
171
180
  * The Agent's memory as the segment starts (track T5, D72), when the version the session runs sets
172
181
  * `harness.memory`: its access (the same in every turn), the agent's instructions, and the index of
173
- * its memories, newest first, within the Tenant's index limits.
182
+ * its memories, newest first, within the Tenant's index limits; no index when it sets
183
+ * `harness.memory.index: false`. `refresh` at a turn's first segment when someone other than the
184
+ * session wrote the memory since its last turn started (`memoryRefreshOf`); the harness then pins
185
+ * this index in place of the one it pinned before. Only our harness is sent it: another v5
186
+ * harness's schema may not know the field.
174
187
  */
175
188
  async function turnMemoryOf(t, s, limits) {
176
189
  const settings = await memorySettingsOf(t, s);
177
190
  if (!settings)
178
191
  return undefined;
179
- const { index, truncated } = await renderMemoryIndex(t, s.agentId, limits);
180
- return {
192
+ const base = {
181
193
  access: settings.access ?? "read_write",
182
194
  ...(settings.instructions === undefined ? {} : { instructions: settings.instructions }),
183
- index,
184
- truncated,
185
195
  };
196
+ // A version that sets `harness.memory.index: false` (ADR-100) pins no index: none is rendered.
197
+ if (settings.index === false)
198
+ return { ...base, truncated: false };
199
+ const { index, truncated } = await renderMemoryIndex(t, s.agentId, limits);
200
+ // Within Harness API 5 only our harness, the Runtime's own version, is sent `refresh`.
201
+ const refresh = (await memoryRefreshOf(t, s)) && harnessOf(s) === HARNESS_ID;
202
+ return { ...base, index, truncated, ...(refresh ? { refresh: true } : {}) };
203
+ }
204
+ /**
205
+ * Whether the harness pins the memory index again at this segment: only at a turn's first segment
206
+ * (never mid-turn), when a memory version newer than the one the session's last turn saw was
207
+ * written other than by this session (another session of the Agent, or the API). Notes the newest
208
+ * version on the session (`Session.memoryIndex`; the caller saves it), and answers a first segment
209
+ * that runs again as it was answered. The session's first turn pins the index anyway.
210
+ */
211
+ async function memoryRefreshOf(t, s) {
212
+ const checkpoint = s.checkpoint;
213
+ const turnId = s.activeTurnId;
214
+ if (turnId === null || checkpoint?.segment !== 0 || checkpoint.turnId !== turnId)
215
+ return false;
216
+ const before = s.memoryIndex;
217
+ if (before?.turnId === turnId)
218
+ return before.refresh;
219
+ const writes = await t.memoryWritesSince(s.agentId, before?.seen, s.id);
220
+ const refresh = before !== undefined && writes.others;
221
+ const seen = writes.newest ?? before?.seen;
222
+ s.memoryIndex = { turnId, ...(seen === undefined ? {} : { seen }), refresh };
223
+ return refresh;
186
224
  }
187
225
  /**
188
226
  * The facts of the Agent a session runs on (Harness API 5): its id and kind, and, with the
@@ -31,6 +31,7 @@ import { HOST_TOKEN_TYP } from "../tenant/host-token.js";
31
31
  import { HostAuthError } from "../sandbox/join.js";
32
32
  import { parseSetupReport } from "../sandbox/setup.js";
33
33
  import { hostTuning } from "../tuning.js";
34
+ import { ENGINE_VERSION_PATTERN, IMAGE_ENGINE_HEADER } from "../harness/image-engine.js";
34
35
  export { HOST_JOIN_PATH, HOST_RENEW_PATH };
35
36
  /** Where a pod's engine reads its setup and reports it (R2d step 2, `sandbox/setup.ts`). */
36
37
  export const HOST_SETUP_PATH = `${HARNESS_API_PATH}/host/setup`;
@@ -45,6 +46,11 @@ function refuse(socket, status, text, headers = {}) {
45
46
  ];
46
47
  socket.end(`${lines.join("\r\n")}\r\n\r\n`);
47
48
  }
49
+ /** The Runtime version an engine built into the image reports at join (ADR-99), when well formed. */
50
+ function imageEngineOf(request) {
51
+ const value = request.headers[IMAGE_ENGINE_HEADER];
52
+ return typeof value === "string" && ENGINE_VERSION_PATTERN.test(value) ? value : undefined;
53
+ }
48
54
  function bearerOf(header) {
49
55
  return /^Bearer\s+(\S+)$/i.exec(header?.trim() ?? "")?.[1];
50
56
  }
@@ -110,7 +116,7 @@ export async function startHarnessListener(options) {
110
116
  return reply(response, 503, { error: { code: "unavailable", message: "Try again" } });
111
117
  try {
112
118
  const answer = path === HOST_JOIN_PATH
113
- ? await authority.join(parseJoin(await readBody(request)))
119
+ ? await authority.join(parseJoin(await readBody(request)), { imageEngine: imageEngineOf(request) })
114
120
  : await authority.renew(bearerOf(request.headers.authorization) ?? "");
115
121
  return reply(response, 200, answer);
116
122
  }
@@ -2,7 +2,7 @@ import type { AgentSecretView, HostModelView, McpPreview, McpPreviewRequest, Put
2
2
  import type { SessionStore } from "../store/types.js";
3
3
  import type { OutboundPolicy } from "../tenant/outbound.js";
4
4
  import { type SigningKeys } from "../tenant/signing-keys.js";
5
- import type { SealedSecretCopies, VaultService } from "../vault/service.js";
5
+ import type { SealedSecretCopies, SecretWriteOptions, VaultService } from "../vault/service.js";
6
6
  /** One JWT to sign: its `typ` and every claim, `iss`, `aud`, `sub`, `iat`, `exp` and `jti` included. */
7
7
  export interface SignRequest {
8
8
  readonly typ: string;
@@ -15,8 +15,11 @@ export interface Signed {
15
15
  }
16
16
  import { type SealedWebhookSecret, type WebhookDelivery } from "./webhooks.js";
17
17
  export interface Keys {
18
- /** `PUT /v1/agents/{id}/secrets/{name}` (`VaultService.putEnvironmentSecret`). */
19
- putEnvironmentSecret(environmentId: string, name: string, body: PutAgentSecretRequest, actor: string): Promise<AgentSecretView>;
18
+ /**
19
+ * `PUT /v1/agents/{id}/secrets/{name}` (`VaultService.putEnvironmentSecret`); `options.viaFromSlot`
20
+ * when `body.via` is the slot's, not the write's own (`secret-routing`).
21
+ */
22
+ putEnvironmentSecret(environmentId: string, name: string, body: PutAgentSecretRequest, actor: string, options?: SecretWriteOptions | null): Promise<AgentSecretView>;
20
23
  putHostModel(body: PutHostModelRequest): Promise<HostModelView>;
21
24
  selectHostModel(body: SelectHostModelRequest): Promise<HostModelView>;
22
25
  /**
package/dist/keys/keys.js CHANGED
@@ -39,7 +39,7 @@ export function inProcessKeys(options) {
39
39
  const { store, vault, signingKeys, kek } = options;
40
40
  const egress = new EgressAuthority(store, kek);
41
41
  return {
42
- putEnvironmentSecret: (environmentId, name, body, actor) => vault.putEnvironmentSecret(environmentId, name, body, actor),
42
+ putEnvironmentSecret: (environmentId, name, body, actor, options) => vault.putEnvironmentSecret(environmentId, name, body, actor, options),
43
43
  putHostModel: (body) => vault.putHostModel(body),
44
44
  selectHostModel: (body) => vault.selectHostModel(body),
45
45
  sealEnvironmentSecretCopies: (from, vaultId, slots) => vault.sealEnvironmentSecretCopies(from, vaultId, slots),