@nylorun/runtime 0.24.0-beta → 0.26.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 (171) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/README.md +18 -4
  3. package/dist/api/a2a/card.d.ts +11 -1
  4. package/dist/api/a2a/card.js +26 -2
  5. package/dist/api/a2a/endpoint.js +4 -2
  6. package/dist/api/a2a/protocol.d.ts +25 -2
  7. package/dist/api/a2a/protocol.js +20 -3
  8. package/dist/api/a2a/routes.d.ts +4 -1
  9. package/dist/api/a2a/routes.js +65 -7
  10. package/dist/api/ag-ui/endpoint.js +5 -2
  11. package/dist/api/ag-ui/history.d.ts +6 -1
  12. package/dist/api/ag-ui/history.js +35 -2
  13. package/dist/api/ag-ui/routes.js +53 -11
  14. package/dist/api/components.d.ts +1165 -678
  15. package/dist/api/components.js +16 -1
  16. package/dist/api/gate/keys.d.ts +2 -1
  17. package/dist/api/gate/keys.js +7 -0
  18. package/dist/api/gate/routes.js +13 -0
  19. package/dist/api/http/app.d.ts +2 -2
  20. package/dist/api/http/app.js +15 -4
  21. package/dist/api/http/body.d.ts +3 -3
  22. package/dist/api/http/body.js +7 -7
  23. package/dist/api/http/define.d.ts +1 -1
  24. package/dist/api/http/respond.js +1 -1
  25. package/dist/api/http/routes/environment-manifests.d.ts +9 -0
  26. package/dist/api/http/routes/environment-manifests.js +118 -0
  27. package/dist/api/http/routes/environments.js +61 -12
  28. package/dist/api/http/routes/reads.d.ts +52 -3
  29. package/dist/api/http/routes/sessions.js +14 -4
  30. package/dist/api/http/routes/tenant.js +35 -7
  31. package/dist/api/inbound-files.d.ts +65 -0
  32. package/dist/api/inbound-files.js +115 -0
  33. package/dist/api/openapi.js +18 -3
  34. package/dist/artifacts/disk.js +20 -14
  35. package/dist/artifacts/media-types.d.ts +15 -0
  36. package/dist/artifacts/media-types.js +56 -4
  37. package/dist/artifacts/parts.d.ts +12 -3
  38. package/dist/artifacts/parts.js +15 -4
  39. package/dist/artifacts/workspace.d.ts +4 -4
  40. package/dist/artifacts/workspace.js +6 -18
  41. package/dist/core/flow-host.d.ts +1 -1
  42. package/dist/core/flow-host.js +2 -2
  43. package/dist/core/sandbox-routes.js +2 -2
  44. package/dist/core/turn-manifest.d.ts +3 -2
  45. package/dist/core/turn-manifest.js +29 -5
  46. package/dist/gates/egress-credentials.js +1 -1
  47. package/dist/gates/egress.d.ts +13 -19
  48. package/dist/gates/egress.js +32 -29
  49. package/dist/gates/http-tool.d.ts +3 -4
  50. package/dist/gates/http-tool.js +23 -19
  51. package/dist/gates/journal.js +0 -1
  52. package/dist/gates/mcp-handler.js +2 -3
  53. package/dist/gates/policy.js +1 -3
  54. package/dist/gates/tool-contract.d.ts +1 -1
  55. package/dist/harness/calls.d.ts +2 -2
  56. package/dist/harness/calls.js +9 -12
  57. package/dist/harness/main.js +7 -1
  58. package/dist/harness/pod.d.ts +6 -1
  59. package/dist/harness/pod.js +5 -0
  60. package/dist/harness/service.js +5 -5
  61. package/dist/harness/setup.d.ts +2 -0
  62. package/dist/harness/setup.js +25 -1
  63. package/dist/harness-api/record.d.ts +1 -1
  64. package/dist/harness-api/record.js +13 -13
  65. package/dist/harness-api/server.d.ts +27 -4
  66. package/dist/harness-api/server.js +41 -8
  67. package/dist/harness-api/tool-journal.d.ts +17 -8
  68. package/dist/harness-api/tool-journal.js +41 -11
  69. package/dist/harness-api/workspace.d.ts +7 -5
  70. package/dist/harness-api/workspace.js +25 -19
  71. package/dist/host/gates.js +2 -2
  72. package/dist/host/stack-config.js +1 -1
  73. package/dist/keys/contract.d.ts +1 -1
  74. package/dist/keys/contract.js +1 -1
  75. package/dist/keys/keys.d.ts +9 -2
  76. package/dist/keys/keys.js +4 -1
  77. package/dist/management-openapi.json +366 -61
  78. package/dist/mcp/connect.d.ts +1 -2
  79. package/dist/mcp/connect.js +1 -4
  80. package/dist/mcp/pool.d.ts +1 -4
  81. package/dist/mcp/pool.js +13 -27
  82. package/dist/mcp/preview.d.ts +1 -1
  83. package/dist/mcp/preview.js +1 -2
  84. package/dist/mcp/snapshot.d.ts +11 -13
  85. package/dist/mcp/snapshot.js +27 -38
  86. package/dist/model/capabilities.d.ts +26 -0
  87. package/dist/model/capabilities.js +44 -0
  88. package/dist/model/catalog.d.ts +34 -3
  89. package/dist/model/catalog.js +38 -2
  90. package/dist/model/models.d.ts +2 -0
  91. package/dist/model/models.js +1 -1
  92. package/dist/model/native-blocks.d.ts +35 -0
  93. package/dist/model/native-blocks.js +170 -0
  94. package/dist/model/pi-model.js +106 -94
  95. package/dist/openapi.json +9554 -5076
  96. package/dist/record/environment.js +1 -1
  97. package/dist/sandbox/join.js +4 -0
  98. package/dist/sandbox/manager.d.ts +32 -14
  99. package/dist/sandbox/manager.js +132 -93
  100. package/dist/sandbox/placement.d.ts +1 -1
  101. package/dist/sandbox/placement.js +2 -2
  102. package/dist/sandbox/pods/client.d.ts +3 -1
  103. package/dist/sandbox/pods/lifecycle.d.ts +8 -0
  104. package/dist/sandbox/pods/lifecycle.js +15 -0
  105. package/dist/sandbox/pods/spec.js +2 -1
  106. package/dist/sandbox/resolve.d.ts +5 -3
  107. package/dist/sandbox/resolve.js +9 -2
  108. package/dist/sandbox/runtime-tools.d.ts +9 -0
  109. package/dist/sandbox/runtime-tools.js +16 -0
  110. package/dist/sandbox/session-sandbox.d.ts +4 -6
  111. package/dist/sandbox/session-sandbox.js +47 -94
  112. package/dist/sandbox/setup.d.ts +4 -1
  113. package/dist/sandbox/setup.js +46 -9
  114. package/dist/sandbox/share.d.ts +12 -7
  115. package/dist/sandbox/share.js +27 -25
  116. package/dist/sandbox/skills.d.ts +8 -6
  117. package/dist/sandbox/skills.js +24 -23
  118. package/dist/store/postgres/drizzle/0024_flat_manifest.sql +7 -0
  119. package/dist/store/postgres/drizzle/0025_environment_manifests.sql +14 -0
  120. package/dist/store/postgres/drizzle/0026_environment_forks.sql +1 -0
  121. package/dist/store/postgres/drizzle/0027_environment_image_digest.sql +1 -0
  122. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  123. package/dist/store/postgres/migrate.js +1 -1
  124. package/dist/store/postgres/reads.js +8 -2
  125. package/dist/store/postgres/schema.d.ts +217 -0
  126. package/dist/store/postgres/schema.js +36 -2
  127. package/dist/store/postgres/store.js +79 -4
  128. package/dist/store/types.d.ts +68 -14
  129. package/dist/tenant/advance.js +3 -0
  130. package/dist/tenant/artifact-tool.d.ts +1 -1
  131. package/dist/tenant/artifact-tool.js +43 -22
  132. package/dist/tenant/commands.js +24 -4
  133. package/dist/tenant/context.d.ts +10 -4
  134. package/dist/tenant/effects.js +7 -9
  135. package/dist/tenant/environment-manifests.d.ts +57 -0
  136. package/dist/tenant/environment-manifests.js +200 -0
  137. package/dist/tenant/environments.d.ts +54 -47
  138. package/dist/tenant/environments.js +309 -85
  139. package/dist/tenant/ephemeral.d.ts +1 -1
  140. package/dist/tenant/host-token.js +1 -1
  141. package/dist/tenant/identity-file.d.ts +1 -1
  142. package/dist/tenant/identity-file.js +1 -1
  143. package/dist/tenant/model-setting.d.ts +14 -0
  144. package/dist/tenant/model-setting.js +51 -0
  145. package/dist/tenant/outbound.d.ts +6 -5
  146. package/dist/tenant/outbound.js +8 -6
  147. package/dist/tenant/sessions.d.ts +5 -1
  148. package/dist/tenant/sessions.js +45 -12
  149. package/dist/tenant/skill-tool.d.ts +3 -3
  150. package/dist/tenant/skill-tool.js +6 -11
  151. package/dist/tenant/sweep.js +1 -1
  152. package/dist/tenant/tool-results.js +1 -1
  153. package/dist/tenant/tool-search.d.ts +7 -11
  154. package/dist/tenant/tool-search.js +8 -5
  155. package/dist/tenant/transcript.d.ts +51 -32
  156. package/dist/tenant/transcript.js +2 -1
  157. package/dist/tenant/types.d.ts +2 -2
  158. package/dist/traces/ids.d.ts +0 -1
  159. package/dist/traces/ids.js +0 -4
  160. package/dist/traces/map.d.ts +2 -2
  161. package/dist/traces/map.js +1 -29
  162. package/dist/vault/coverage.js +11 -13
  163. package/dist/vault/host-model.d.ts +4 -1
  164. package/dist/vault/host-model.js +10 -2
  165. package/dist/vault/service.d.ts +49 -4
  166. package/dist/vault/service.js +153 -6
  167. package/dist/version.d.ts +1 -1
  168. package/dist/version.js +1 -1
  169. package/package.json +3 -3
  170. package/dist/sandbox/capability.d.ts +0 -3
  171. package/dist/sandbox/capability.js +0 -10
@@ -11,12 +11,16 @@
11
11
  * keyed by the Environment id and created on the first tool call), `pod` (an agent-sandbox pod
12
12
  * on the Tenant's cluster, Host feature `sandbox-pods`, created at once) or `none` (no compute).
13
13
  * Each has one vault (`vault/service.ts`), created and deleted with it in one transaction.
14
- * - **A recipe that changes.** `PUT` on an existing id compares the fields sent: `kind` and
15
- * `storage` are fixed (`409`), labels replace, and any other change writes the recipe and bumps
16
- * `version`. A pod runs `applied`: image and resources reach it when it is next asked to run
17
- * from stopped (`wantRunning`), so nothing swaps them mid-turn; so does `harness`, which picks
18
- * what the pod runs (R4.4). Network and lifecycle apply at once. A virtual or `none`
19
- * Environment applies every change at once.
14
+ * - **Made once, never changed** (protocol 14, D63). An Environment is made from one version of
15
+ * an environment manifest (`manifest: "name"` or `"name@n"`, `environment-manifests.ts`) or
16
+ * from an inline manifest (the fields of the `PUT`, its `recipe`), and keeps what it runs for
17
+ * life: a `PUT` on an existing id may change its labels and owner guard, a field that matches
18
+ * what it runs is accepted (so get-or-create stays one call), and anything else is
19
+ * `409 environment_immutable`, naming the fork that makes a new Environment instead.
20
+ * `version` and `appliedVersion` stay 1 for an Environment made since then.
21
+ * - **Secret slots** (D64): when what it runs declares `secrets`, a secret write sends only the
22
+ * value, the binding comes from the slot, and a name it does not declare is
23
+ * `409 secret_not_in_manifest` (`secretRequestFor`).
20
24
  * - **Access.** An application key reaches every Environment, and so does an application key
21
25
  * acting for a subject (`Nylorun-Subject`), which needs `environments:write` to change one. A
22
26
  * token caller reaches only the ids its grants match, and changes them only with
@@ -24,8 +28,10 @@
24
28
  * - **The owner guard.** When `ownerUserId` is set, only that owner's sessions may name the
25
29
  * Environment, checked when a session opens and at every turn start (`403
26
30
  * environment_owner_mismatch`; the opaque 404 to a caller acting for a person).
27
- * - **Serial turns.** One turn at a time per Environment with a sandbox: a session whose
28
- * Environment another session's turn holds is refused (`409 environment_busy`).
31
+ * - **Parallel turns (D66).** Sessions on one Environment run their turns at the same time and
32
+ * share its `/workspace`; one turn per session stays. Which sessions work on one disk, and when,
33
+ * is the app's call. Disk work (snapshot, restore, stop, reset) waits until no turn runs
34
+ * (`409 environment_busy`), and a turn waits while a seed copies a disk.
29
35
  * - **Lifecycle.** `POST .../stop` suspends a pod (the volume is kept; the next turn starts it),
30
36
  * `POST .../reset` gives it a new pod on a new volume. A lost pod refuses turns
31
37
  * (`environment_lost`) until a reset. `lifecycle.deleteAfter` deletes an Environment unused
@@ -34,7 +40,9 @@
34
40
  * - **Lifecycle events** go to the Environment's own stream in the record (`Tx.environmentEvent`,
35
41
  * `record/environment.ts`); the session's log records `environment.attached` too.
36
42
  */
37
- import { environmentGranted, } from "@nylorun/core/contracts";
43
+ import { randomUUID } from "node:crypto";
44
+ import { environmentGranted, parseEnvironmentManifestRef, PutEnvironmentSecretRequestSchema, PutEnvironmentSecretValueRequestSchema, } from "@nylorun/core/contracts";
45
+ import { hashManifest } from "@nylorun/core/compatibility";
38
46
  import { isWorkflowManifest, parseSandboxDuration } from "@nylorun/core/define";
39
47
  import { canonical } from "../store/canonical.js";
40
48
  import { resolveRecipe } from "../sandbox/resolve.js";
@@ -44,7 +52,8 @@ import { podName } from "../sandbox/pods/name.js";
44
52
  import { podLifecycleConfig } from "../sandbox/pods/spec.js";
45
53
  import { SETUP_FAILED } from "../sandbox/pods/lifecycle.js";
46
54
  import { requirePods } from "../sandbox/placement.js";
47
- import { deleteDiskArtifact, forkSeedOf, runPendingSeed } from "../artifacts/disk.js";
55
+ import { deleteDiskArtifact, forkSeedOf, runPendingSeed, snapshotEnvironment } from "../artifacts/disk.js";
56
+ import { manifestChanges, manifestRecipeRequest } from "./environment-manifests.js";
48
57
  import { harnessOf, sessionOf } from "./context.js";
49
58
  import { fail } from "./http.js";
50
59
  /** The Environment grants that limit `scope`, or undefined when it reaches every Environment. */
@@ -91,11 +100,17 @@ async function viewOf(ctx, t, environment, owner) {
91
100
  ? await t.get("sandboxes", sandboxWorkspaceKey(ctx.config.tenantId, environment.id))
92
101
  : undefined;
93
102
  const pod = environment.pod;
103
+ const slots = await slotsOf(t, environment);
94
104
  return {
95
105
  id: environment.id,
96
106
  kind: environment.kind,
97
107
  labels: environment.labels,
98
- recipe: environment.recipe,
108
+ manifest: environment.recipe,
109
+ ...(environment.manifestName === undefined ? {} : { manifestName: environment.manifestName }),
110
+ ...(environment.manifestVersion === undefined ? {} : { manifestVersion: environment.manifestVersion }),
111
+ ...(environment.manifestHash === undefined ? {} : { manifestHash: environment.manifestHash }),
112
+ ...(environment.forkedFrom === undefined ? {} : { forkedFrom: environment.forkedFrom }),
113
+ ...(slots === undefined ? {} : { slots }),
99
114
  version: environment.version,
100
115
  appliedVersion: environment.appliedVersion,
101
116
  ownerUserId: environment.ownerUserId ?? null,
@@ -108,6 +123,7 @@ async function viewOf(ctx, t, environment, owner) {
108
123
  volumeGeneration: pod.volumeGen,
109
124
  hostEpoch: pod.hostEpoch,
110
125
  ...(pod.reason ? { reason: pod.reason } : {}),
126
+ ...(pod.imageDigest ? { imageDigest: pod.imageDigest } : {}),
111
127
  },
112
128
  }
113
129
  : {}),
@@ -135,6 +151,34 @@ async function viewOf(ctx, t, environment, owner) {
135
151
  updatedAt: environment.updatedAt,
136
152
  };
137
153
  }
154
+ /** Each secret slot `environment` declares, and whether its vault holds a value for it. */
155
+ async function slotsOf(t, environment) {
156
+ const declared = environment.recipe.secrets;
157
+ if (declared === undefined)
158
+ return undefined;
159
+ const vault = await t.vaultOfEnvironment(environment.id);
160
+ const held = new Set(vault ? (await t.credentialsForVault(vault.id)).map((row) => row.name) : []);
161
+ return Object.entries(declared)
162
+ .sort(([a], [b]) => a.localeCompare(b))
163
+ .map(([name, slot]) => ({ name, type: slot.type, required: slot.required ?? false, set: held.has(name) }));
164
+ }
165
+ /** The inline fields of a `PUT`: what it would run, besides labels, owner and `from`. */
166
+ const INLINE_FIELDS = [
167
+ "kind",
168
+ "image",
169
+ "resources",
170
+ "storage",
171
+ "network",
172
+ "harness",
173
+ "lifecycle",
174
+ "setup",
175
+ "files",
176
+ "secrets",
177
+ ];
178
+ /** The hash of what an Environment runs, recorded when it is made. */
179
+ function recipeHash(recipe) {
180
+ return hashManifest(recipe);
181
+ }
138
182
  /** What a `PUT` asks of the recipe: its fields over `existing`'s (fixed `storage` kept). */
139
183
  function recipeRequest(body, existing) {
140
184
  const recipe = existing?.recipe;
@@ -152,20 +196,38 @@ function recipeRequest(body, existing) {
152
196
  ...pick("lifecycle", body.lifecycle),
153
197
  ...pick("setup", body.setup),
154
198
  ...pick("files", body.files),
199
+ ...pick("secrets", body.secrets),
155
200
  };
156
201
  }
157
202
  /**
158
- * Refuses input files that are not a version of a Tenant-wide artifact (a session's artifacts
159
- * go with the session); pins nothing else: the version is the one named.
203
+ * Why input files cannot be used: each that is not a version of a Tenant-wide artifact (a
204
+ * session's artifacts go with the session). Pins nothing else: the version is the one named.
160
205
  */
161
- async function checkFiles(t, files) {
206
+ export async function fileErrors(t, files) {
207
+ const errors = [];
162
208
  for (const file of files ?? []) {
209
+ if ("files" in file) {
210
+ // Files by hash (Q6): each a definition file the Tenant holds.
211
+ const wanted = [...new Set(Object.values(file.files))];
212
+ const held = await t.heldDefinitionFiles(wanted);
213
+ for (const [path, sha256] of Object.entries(file.files))
214
+ if (!held.has(sha256))
215
+ errors.push(`files: ${file.path}/${path} names ${sha256}, which the Tenant does not hold: upload it first (PUT /v1/files/${sha256}).`);
216
+ continue;
217
+ }
163
218
  const artifact = await t.artifact(file.artifactId);
164
219
  if (!artifact || artifact.sessionId !== null)
165
- fail(400, `files: ${file.artifactId} is not an artifact of the Tenant (an artifact of a session cannot be one).`);
166
- if (!(await t.artifactVersion(file.artifactId, file.version)))
167
- fail(400, `files: ${file.artifactId} has no version ${file.version}.`);
220
+ errors.push(`files: ${file.artifactId} is not an artifact of the Tenant (an artifact of a session cannot be one).`);
221
+ else if (!(await t.artifactVersion(file.artifactId, file.version)))
222
+ errors.push(`files: ${file.artifactId} has no version ${file.version}.`);
168
223
  }
224
+ return errors;
225
+ }
226
+ /** Refuses input files that cannot be used (`fileErrors`). */
227
+ async function checkFiles(t, files) {
228
+ const errors = await fileErrors(t, files);
229
+ if (errors.length > 0)
230
+ fail(400, errors.join(" "));
169
231
  }
170
232
  /** Refuses an owner guard a caller acting for a person may not set. */
171
233
  function checkGuard(scope, asked, current) {
@@ -213,15 +275,33 @@ async function wantRunning(t, environment, now) {
213
275
  }
214
276
  /**
215
277
  * `PUT /v1/environments/{id}`: creates the Environment and its vault (within the Tenant's limit
216
- * on their number), or finds the one with this id, so "get or create" is one call. On an
217
- * existing one, `kind` and `storage` must match, labels replace, the owner guard is set or
218
- * cleared, and a recipe change bumps `version`. Every `PUT` counts as the Environment's use.
278
+ * on their number) from an environment manifest's version or from the inline fields, or finds
279
+ * the one with this id, so "get or create" is one call. An existing one never changes what it
280
+ * runs (D63): its labels and owner guard may change, fields that match are accepted, and any
281
+ * other difference is `409 environment_immutable`. Every `PUT` counts as the Environment's use.
219
282
  */
220
- export async function putEnvironment(ctx, id, body, scope) {
283
+ export async function putEnvironment(ctx, id, body, scope, options = {}) {
284
+ const fork = options.fork;
221
285
  reachable(environmentGrantsOf(scope), id);
286
+ const ref = body.manifest === undefined ? undefined : parseEnvironmentManifestRef(body.manifest);
287
+ if (body.manifest !== undefined) {
288
+ if (ref === undefined)
289
+ fail(400, "manifest is an environment manifest's name, or name@version such as analyst-box@3.");
290
+ const inline = INLINE_FIELDS.filter((field) => body[field] !== undefined);
291
+ if (inline.length > 0)
292
+ fail(400, `An Environment made from manifest ${body.manifest} runs what the manifest says: send no ${inline.join(", ")} with it.`);
293
+ }
222
294
  if (body.kind === "pod")
223
295
  await requirePods(ctx.pods, "A pod Environment");
224
- const conflict = (field) => fail(409, `Environment ${id} exists with another ${field}, which is fixed: delete it to create it again.`);
296
+ const immutable = (fields, existing) => fail(409, `Environment ${id} exists and never changes what it runs (${fields.join(", ")} differ${fields.length === 1 ? "s" : ""}): fork it into a new Environment with POST /v1/environments/${encodeURIComponent(id)}/fork, or use another id.`, {
297
+ code: "environment_immutable",
298
+ details: {
299
+ fields: [...fields],
300
+ ...(existing.manifestName === undefined
301
+ ? {}
302
+ : { manifest: `${existing.manifestName}@${existing.manifestVersion}` }),
303
+ },
304
+ });
225
305
  const view = await ctx.store.tx(async (t) => {
226
306
  const now = new Date().toISOString();
227
307
  const config = effectiveSandboxConfig(await readSandboxConfig(t));
@@ -237,56 +317,36 @@ export async function putEnvironment(ctx, id, body, scope) {
237
317
  }, { "retry-after": "5" });
238
318
  };
239
319
  const found = async (existing) => {
320
+ if (fork !== undefined)
321
+ fail(409, `Environment ${id} exists: a fork makes a new Environment, so name another id.`);
240
322
  if (body.from !== undefined)
241
323
  fail(409, `Environment ${id} exists: from applies only when a PUT creates one.`);
242
- if (body.kind !== undefined && body.kind !== existing.kind)
243
- conflict("kind");
244
- if (body.storage !== undefined && body.storage !== existing.recipe.storage)
245
- conflict("storage");
324
+ if (ref !== undefined) {
325
+ // A bare name matches the manifest it was made from, whatever its latest version is now.
326
+ if (ref.name !== existing.manifestName)
327
+ immutable(["manifest"], existing);
328
+ if (ref.version !== undefined && ref.version !== existing.manifestVersion)
329
+ immutable(["manifest"], existing);
330
+ }
331
+ else if (INLINE_FIELDS.some((field) => body[field] !== undefined)) {
332
+ if (body.kind !== undefined && body.kind !== existing.kind)
333
+ immutable(["kind"], existing);
334
+ if (body.storage !== undefined && body.storage !== existing.recipe.storage)
335
+ immutable(["storage"], existing);
336
+ const recipe = resolve(existing.kind, recipeRequest(body, existing));
337
+ const differing = Object.keys({ ...recipe, ...existing.recipe })
338
+ .filter((field) => canonical(recipe[field]) !== canonical(existing.recipe[field]))
339
+ .sort();
340
+ if (differing.length > 0)
341
+ immutable(differing, existing);
342
+ }
246
343
  checkGuard(scope, body.ownerUserId, existing.ownerUserId);
247
- const recipe = resolve(existing.kind, recipeRequest(body, existing));
248
- if (body.files !== undefined)
249
- await checkFiles(t, recipe.files);
250
344
  const patch = { lastUsedAt: now };
251
345
  if (body.labels !== undefined && canonical(body.labels) !== canonical(existing.labels))
252
346
  patch.labels = body.labels;
253
347
  if (body.ownerUserId !== undefined && (body.ownerUserId ?? undefined) !== existing.ownerUserId)
254
348
  patch.ownerUserId = body.ownerUserId;
255
- if (canonical(recipe) !== canonical(existing.recipe)) {
256
- const version = existing.version + 1;
257
- patch.recipe = recipe;
258
- patch.version = version;
259
- // What waits for the pod to start from stopped; setup and files run only on a new disk.
260
- // The harness is what a pod runs (R4.4): it changes with the pod, like its image.
261
- const compute = (r) => canonical({
262
- image: r.image ?? null,
263
- resources: r.resources ?? null,
264
- setup: r.setup ?? null,
265
- files: r.files ?? null,
266
- harness: r.harness,
267
- });
268
- if (existing.kind !== "pod" || compute(recipe) === compute(existing.applied)) {
269
- // Nothing waits for a wake: apply all of it.
270
- patch.applied = recipe;
271
- patch.appliedVersion = version;
272
- }
273
- else
274
- // Network and lifecycle apply at once; image, resources and harness at the next wake.
275
- patch.applied = {
276
- ...existing.applied,
277
- ...(recipe.network === undefined ? {} : { network: recipe.network }),
278
- lifecycle: recipe.lifecycle,
279
- };
280
- }
281
349
  await t.updateEnvironment(id, patch, now);
282
- if (patch.recipe !== undefined && existing.pod?.desired === "running")
283
- // A new `lifecycle.idle` applies to the running pod's idle timer.
284
- await t.signalSandbox(id, {
285
- kind: "arm",
286
- timer: "idle",
287
- at: Date.parse(existing.pod.lastActiveAt ?? existing.createdAt) +
288
- podLifecycleConfig(await readSandboxConfig(t), patch.recipe).idleMs,
289
- });
290
350
  return viewOf(ctx, t, (await t.environment(id)) ?? existing, subjectOf(scope));
291
351
  };
292
352
  const existing = await t.environment(id, { lock: true });
@@ -294,10 +354,20 @@ export async function putEnvironment(ctx, id, body, scope) {
294
354
  if (existing)
295
355
  return found(existing);
296
356
  checkGuard(scope, body.ownerUserId);
297
- const kind = body.kind ?? "virtual";
298
- const recipe = resolve(kind, recipeRequest(body));
357
+ const manifest = ref === undefined
358
+ ? undefined
359
+ : ((await t.environmentManifest(ref.name, ref.version)) ??
360
+ fail(404, ref.version === undefined
361
+ ? `No environment manifest is named ${ref.name}: save it first (PUT /v1/environment-manifests/${ref.name}).`
362
+ : `Environment manifest ${ref.name} has no version ${ref.version}.`, { code: "manifest_not_found" }));
363
+ const kind = manifest ? (manifest.body.kind ?? "virtual") : (body.kind ?? "virtual");
364
+ if (manifest && kind === "pod")
365
+ await requirePods(ctx.pods, `Environment manifest ${manifest.name} is of kind pod, and a pod Environment`);
366
+ const recipe = resolve(kind, manifest ? manifestRecipeRequest(manifest.body) : recipeRequest(body));
299
367
  await checkFiles(t, recipe.files);
300
- const seed = body.from === undefined ? undefined : await forkSource(t, scope, kind, body.from);
368
+ const from = body.from === undefined ? undefined : await forkSource(t, scope, kind, body.from);
369
+ // A fork onto other setup runs it on the written disk (Q17).
370
+ const seed = from && fork?.setup ? { ...from, setup: true } : from;
301
371
  const row = {
302
372
  id,
303
373
  kind,
@@ -310,6 +380,9 @@ export async function putEnvironment(ctx, id, body, scope) {
310
380
  lastUsedAt: now,
311
381
  createdAt: now,
312
382
  updatedAt: now,
383
+ ...(manifest ? { manifestName: manifest.name, manifestVersion: manifest.version } : {}),
384
+ manifestHash: recipeHash(recipe),
385
+ ...(fork ? { forkedFrom: { environment: fork.source, disk: seed?.version ?? null } } : {}),
313
386
  ...(seed
314
387
  ? {
315
388
  diskSeed: seed,
@@ -348,12 +421,24 @@ export async function putEnvironment(ctx, id, body, scope) {
348
421
  deleting(again);
349
422
  return found(again);
350
423
  }
351
- await ctx.vault.createEnvironmentVault(t, id, actorOf(scope));
424
+ const vaultId = await ctx.vault.createEnvironmentVault(t, id, actorOf(scope), fork?.sealed?.vaultId);
425
+ if (fork?.sealed)
426
+ fork.copied = await ctx.vault.insertEnvironmentSecretCopies(t, vaultId, fork.source, actorOf(scope), fork.sealed.copies);
427
+ const madeFrom = manifest ? { manifest: { name: manifest.name, version: manifest.version } } : {};
352
428
  await t.environmentEvent(id, "environment.created", {
353
429
  kind,
354
430
  labels: row.labels,
431
+ ...madeFrom,
355
432
  ...(seed ? { from: { environment: body.from.environment, version: seed.version } } : {}),
433
+ ...(row.forkedFrom ? { forkedFrom: row.forkedFrom } : {}),
356
434
  });
435
+ if (fork)
436
+ await t.environmentEvent(fork.source, "environment.forked", {
437
+ to: id,
438
+ disk: seed?.version ?? null,
439
+ ...madeFrom,
440
+ secrets: fork.copied?.copied.length ?? 0,
441
+ });
357
442
  if (row.pod) {
358
443
  // Created at once; idle from now if no turn comes.
359
444
  await reconcileAfter(t, id);
@@ -369,6 +454,45 @@ export async function putEnvironment(ctx, id, body, scope) {
369
454
  }
370
455
  return view;
371
456
  }
457
+ /**
458
+ * The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
459
+ * slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
460
+ * `{ headers }` for a `headers` slot) with the binding from the slot; a full typed body is
461
+ * accepted only when its binding is the slot's. Without slots, today's typed body.
462
+ */
463
+ export async function secretRequestFor(ctx, id, name, raw) {
464
+ const environment = await ctx.store.tx((t) => liveEnvironment(t, id));
465
+ const slots = environment?.recipe.secrets;
466
+ if (!environment || slots === undefined)
467
+ return PutEnvironmentSecretRequestSchema.parse(raw);
468
+ const slot = slots[name] ??
469
+ fail(409, `What Environment ${id} runs declares no secret ${name}${environment.manifestName ? ` (manifest ${environment.manifestName}@${environment.manifestVersion})` : ""}: add the slot to the manifest, then make or fork an Environment from the new version.`, { code: "secret_not_in_manifest", details: { declared: Object.keys(slots).sort() } });
470
+ const full = slotRequest(slot);
471
+ if (raw !== null && typeof raw === "object" && "type" in raw) {
472
+ const body = PutEnvironmentSecretRequestSchema.parse(raw);
473
+ const { requestId: _, value: _v, headers: _h, ...binding } = body;
474
+ if (canonical(binding) !== canonical(full))
475
+ fail(409, `Secret ${name}'s type and binding come from what Environment ${id} runs: send only its value ({ "value": "…" }).`, {
476
+ code: "secret_not_in_manifest",
477
+ });
478
+ return body;
479
+ }
480
+ const { requestId, value, headers } = PutEnvironmentSecretValueRequestSchema.parse(raw);
481
+ const withId = requestId === undefined ? {} : { requestId };
482
+ if (slot.type === "headers") {
483
+ if (headers === undefined)
484
+ fail(400, `Secret ${name} is a headers credential: send { "headers": { … } }.`);
485
+ return PutEnvironmentSecretRequestSchema.parse({ ...withId, ...full, headers });
486
+ }
487
+ if (value === undefined)
488
+ fail(400, `Secret ${name} takes a value: send { "value": "…" }.`);
489
+ return PutEnvironmentSecretRequestSchema.parse({ ...withId, ...full, value });
490
+ }
491
+ /** A slot's type and binding, as a secret write's fields (without the value). */
492
+ function slotRequest(slot) {
493
+ const { required: _r, description: _d, ...binding } = slot;
494
+ return binding;
495
+ }
372
496
  /**
373
497
  * The disk version a create's `from` names (R2d step 3): the caller's grants must reach the
374
498
  * source, a caller acting for a person must be its guard's owner, and kind `none` has no disk.
@@ -383,6 +507,113 @@ async function forkSource(t, scope, kind, from) {
383
507
  notFound();
384
508
  return forkSeedOf(t, from);
385
509
  }
510
+ /**
511
+ * `POST /v1/environments/{id}/fork` (Q15): how an Environment gets a new manifest version. A new
512
+ * Environment `body.id` is made from the manifest version (default the source's manifest at its
513
+ * latest), its disk started from a version of the source's (`latest` takes a snapshot first,
514
+ * refused during a turn), and its secrets copied inside the Runtime. A fork onto other setup
515
+ * runs that setup on the copied disk before its first turn (Q17). The source keeps what it runs,
516
+ * its disk and its sessions. With `dryRun`, the plan only.
517
+ */
518
+ export async function forkEnvironment(ctx, sourceId, body, scope) {
519
+ const grants = environmentGrantsOf(scope);
520
+ reachable(grants, sourceId);
521
+ reachable(grants, body.id);
522
+ if (body.id === sourceId)
523
+ fail(400, "A fork is a new Environment: give it another id.");
524
+ const { plan: planned, slots } = await ctx.store.tx(async (t) => {
525
+ const source = (await liveEnvironment(t, sourceId)) ?? notFound();
526
+ const subject = subjectOf(scope);
527
+ if (subject !== undefined && source.ownerUserId !== undefined && source.ownerUserId !== subject)
528
+ notFound();
529
+ const ref = body.manifest === undefined ? undefined : parseEnvironmentManifestRef(body.manifest);
530
+ if (body.manifest !== undefined && ref === undefined)
531
+ fail(400, "manifest is an environment manifest's name, or name@version such as analyst-box@3.");
532
+ const name = ref?.name ??
533
+ source.manifestName ??
534
+ fail(400, `Environment ${sourceId} was made from inline fields, not a manifest: name the manifest to fork it onto (manifest).`);
535
+ const manifest = (await t.environmentManifest(name, ref?.version)) ??
536
+ fail(404, ref?.version === undefined
537
+ ? `No environment manifest is named ${name}.`
538
+ : `Environment manifest ${name} has no version ${ref.version}.`, { code: "manifest_not_found" });
539
+ const kind = manifest.body.kind ?? "virtual";
540
+ const resolved = resolveRecipe(kind, manifestRecipeRequest(manifest.body), effectiveSandboxConfig(await readSandboxConfig(t)));
541
+ if (resolved.kind === "error")
542
+ return fail(400, resolved.errors.join(" "));
543
+ const recipe = resolved.recipe;
544
+ const asked = body.disk ?? "latest";
545
+ const disk = kind === "none" || source.kind === "none" || asked === "empty"
546
+ ? null
547
+ : asked === "latest"
548
+ ? "latest"
549
+ : (await forkSeedOf(t, { environment: sourceId, version: asked })).version;
550
+ const vault = await t.vaultOfEnvironment(sourceId);
551
+ const names = vault
552
+ ? (await t.credentialsForVault(vault.id)).filter((row) => row.type !== "model").map((row) => row.name).sort()
553
+ : [];
554
+ const copied = body.secrets === "none" ? [] : names.filter((item) => recipe.secrets === undefined || item in recipe.secrets);
555
+ const plan = {
556
+ source: sourceId,
557
+ id: body.id,
558
+ kind,
559
+ manifest: { name: manifest.name, version: manifest.version },
560
+ disk,
561
+ setup: kind === "pod" &&
562
+ disk !== null &&
563
+ recipeHash(recipe) !== source.manifestHash &&
564
+ Boolean(recipe.setup?.length || recipe.files?.length),
565
+ secrets: {
566
+ copied,
567
+ dropped: names.filter((item) => !copied.includes(item)),
568
+ missing: Object.entries(recipe.secrets ?? {})
569
+ .filter(([item, slot]) => slot.required === true && !copied.includes(item))
570
+ .map(([item]) => item)
571
+ .sort(),
572
+ },
573
+ changes: manifestChanges(source.recipe, recipe),
574
+ sessions: (await t.sessionsOnEnvironment(sourceId)).length,
575
+ };
576
+ const slots = recipe.secrets === undefined
577
+ ? null
578
+ : Object.fromEntries(Object.entries(recipe.secrets).map(([item, slot]) => [item, slotRequest(slot)]));
579
+ return { plan, slots };
580
+ });
581
+ if (body.dryRun)
582
+ return { dryRun: true, plan: planned };
583
+ // `latest`: a version of the disk as it is now, between turns (an unchanged disk makes none).
584
+ const disk = planned.disk === "latest" ? (await snapshotEnvironment(ctx, sourceId, scope)).version : planned.disk;
585
+ // Keys holds the vault key (F4.2): it seals the copies first, outside the create's transaction,
586
+ // and the create inserts them, so the fork is still one commit.
587
+ const vaultId = randomUUID();
588
+ const sealed = (body.secrets ?? "copy") === "copy"
589
+ ? { vaultId, copies: await ctx.keys.sealEnvironmentSecretCopies(sourceId, vaultId, slots) }
590
+ : undefined;
591
+ const fork = { source: sourceId, setup: planned.setup, ...(sealed ? { sealed } : {}) };
592
+ const environment = await putEnvironment(ctx, body.id, {
593
+ ...(body.requestId === undefined ? {} : { requestId: body.requestId }),
594
+ manifest: `${planned.manifest.name}@${planned.manifest.version}`,
595
+ ...(body.labels === undefined ? {} : { labels: body.labels }),
596
+ ...(body.ownerUserId === undefined ? {} : { ownerUserId: body.ownerUserId }),
597
+ ...(disk === null ? {} : { from: { environment: sourceId, version: disk } }),
598
+ }, scope, { fork });
599
+ const copied = fork.copied ?? { copied: [], dropped: planned.secrets.copied };
600
+ const dropped = [...new Set([...planned.secrets.dropped, ...copied.dropped])].sort();
601
+ return {
602
+ environment,
603
+ plan: {
604
+ ...planned,
605
+ disk,
606
+ secrets: {
607
+ copied: copied.copied,
608
+ dropped,
609
+ missing: Object.entries(environment.manifest.secrets ?? {})
610
+ .filter(([item, slot]) => slot.required === true && !copied.copied.includes(item))
611
+ .map(([item]) => item)
612
+ .sort(),
613
+ },
614
+ },
615
+ };
616
+ }
386
617
  /** `GET /v1/environments/{id}`. */
387
618
  export async function getEnvironment(ctx, id, scope) {
388
619
  reachable(environmentGrantsOf(scope), id);
@@ -645,11 +876,9 @@ export async function newVolume(ctx, t, environment, options = {}) {
645
876
  /** How long a pending seed holds the disk before another process may take it over. */
646
877
  export const SEED_LEASE_MS = 10 * 60_000;
647
878
  /** `GET /v1/environments/{id}/events`: the Environment's lifecycle stream. */
648
- export async function environmentEventsOf(ctx, id, scope, fromSeq) {
879
+ export async function environmentEventsOf(ctx, id, scope, query = {}) {
649
880
  reachable(environmentGrantsOf(scope), id);
650
- return ctx.store.tx(async (t) => ({
651
- events: await t.environmentEvents(id, fromSeq === undefined ? {} : { fromSeq }),
652
- }));
881
+ return ctx.store.tx(async (t) => ({ events: await t.environmentEvents(id, query) }));
653
882
  }
654
883
  /**
655
884
  * The owner guard (Q7): refuses a session of `ownerUserId` on an Environment guarded for
@@ -681,9 +910,9 @@ export async function recordAttachment(t, sessionId, environmentId) {
681
910
  /**
682
911
  * Checks a turn start on a session that names an Environment, in the command's transaction
683
912
  * after the session row lock: the Environment still exists, a token caller's grants reach it,
684
- * its owner guard admits the session's owner, and, with a sandbox, no other session's turn
685
- * holds it. Holds the Environment row's lock until the transaction ends, so two turn starts on
686
- * one Environment are decided one after the other.
913
+ * its owner guard admits the session's owner, and no disk copy holds it. Other sessions' turns
914
+ * do not stop it (D66). Holds the Environment row's lock until the transaction ends, so a turn
915
+ * start and disk work on one Environment are decided one after the other.
687
916
  *
688
917
  * A pod Environment must also be usable: not lost (`environment_lost`), on a Runtime with
689
918
  * sandbox pods (`environment_unavailable`). A stopped or failed pod is asked to run again, with
@@ -706,11 +935,14 @@ export async function checkEnvironmentTurn(t, session, scope, ctx) {
706
935
  code: "environment_owner_mismatch",
707
936
  });
708
937
  // The harness the turn would run on (R4.1, Q9): a pod about to wake runs its recipe's. A
709
- // session never moves to another harness: its state is that harness's.
938
+ // session never moves to another harness: its state is that harness's. An Environment's
939
+ // harness never changes since protocol 14, so this refuses a session whose Environment id was
940
+ // deleted and made again with another harness, and an Environment changed in place before
941
+ // protocol 14 whose recipe (a pod's, at its wake) names another harness than it applied.
710
942
  const waking = environment.pod !== undefined && (environment.pod.desired !== "running" || environment.pod.observed === "failed");
711
943
  const runs = waking ? environment.recipe.harness : environment.applied.harness;
712
944
  if (!isWorkflowManifest(session.manifest) && runs !== harnessOf(session))
713
- fail(409, `Environment ${id} now runs the ${runs} harness, and this session runs ${harnessOf(session)}: open a new session to run on it.`, {
945
+ fail(409, `Environment ${id} runs the ${runs} harness, and this session runs ${harnessOf(session)}: open a new session to run on it.`, {
714
946
  code: "harness_mismatch",
715
947
  details: { harness: harnessOf(session), environmentHarness: runs },
716
948
  });
@@ -718,19 +950,11 @@ export async function checkEnvironmentTurn(t, session, scope, ctx) {
718
950
  return;
719
951
  // A snapshot or restore copies the disk between turns (R2d step 3); a seed's turn waits.
720
952
  refuseDiskCopy(environment);
721
- // A flow tree's sessions nest their turns in its root's: one holder.
722
- const tree = session.flowRootId ?? session.id;
723
- const holder = (await t.sessionsOnEnvironment(id)).find((other) => other.id !== session.id && other.activeTurnId !== null && (other.flowRootId ?? other.id) !== tree);
724
- if (holder)
725
- fail(409, `Environment ${id} is running another session's turn. Turns are serial per Environment.`, {
726
- code: "environment_busy",
727
- details: { retryAfterSeconds: 5 },
728
- }, { "retry-after": "5" });
729
953
  const pod = environment.pod;
730
954
  if (!pod)
731
955
  return;
732
956
  if (!ctx?.pods)
733
- fail(409, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods: run \`nylorun sandbox enable --context <name>\`.`, {
957
+ fail(409, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods: run \`nylorun local sandbox enable --context <name>\`.`, {
734
958
  code: "environment_unavailable",
735
959
  });
736
960
  if (pod.observed === "lost")
@@ -31,7 +31,7 @@ export interface StartEphemeralRuntimeOptions {
31
31
  /** When true, close() leaves hostRoot on disk. */
32
32
  retainRoot?: boolean;
33
33
  /**
34
- * Trusted issuers whose JWTs the Tenant API accepts (Host feature `trusted-issuers`): the
34
+ * Trusted issuers whose JWTs the Runtime API accepts (Host feature `trusted-issuers`): the
35
35
  * issuers of an identity file (`parseIdentityFile`), or ones already built with
36
36
  * `createTrustedIssuers`. Default none.
37
37
  */
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * The host token is accepted only by the Harness API listener (a connection serving that one
18
18
  * sandbox, and `host.renew`); the egress token only by egress-gate, as the proxy credential
19
- * (`Proxy-Authorization`). Neither is a bearer anywhere else: the Tenant API's bearer check
19
+ * (`Proxy-Authorization`). Neither is a bearer anywhere else: the Runtime API's bearer check
20
20
  * accepts trusted issuers' tokens only, and the gates run tokens only.
21
21
  *
22
22
  * Verification reads the key row on every call (a revoked key refuses at once) and, with
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The identity file (Host feature `trusted-issuers`, F9 I2): the YAML file
3
- * `NYLORUN_IDENTITY_FILE` names, listing the issuers whose JWTs the Tenant API accepts as
3
+ * `NYLORUN_IDENTITY_FILE` names, listing the issuers whose JWTs the Runtime API accepts as
4
4
  * bearers. Read once at boot (`host/main.ts`); a change takes a restart. A malformed file stops
5
5
  * the boot with a message naming the issuer and the field. A key the file does not define is
6
6
  * ignored with a warning (`maxLifetime`, gone in protocol 9, among them), so an older file
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The identity file (Host feature `trusted-issuers`, F9 I2): the YAML file
3
- * `NYLORUN_IDENTITY_FILE` names, listing the issuers whose JWTs the Tenant API accepts as
3
+ * `NYLORUN_IDENTITY_FILE` names, listing the issuers whose JWTs the Runtime API accepts as
4
4
  * bearers. Read once at boot (`host/main.ts`); a change takes a restart. A malformed file stops
5
5
  * the boot with a message naming the issuer and the field. A key the file does not define is
6
6
  * ignored with a warning (`maxLifetime`, gone in protocol 9, among them), so an older file
@@ -1,4 +1,7 @@
1
1
  /**
2
+ * The Tenant's model settings: the fixture model, and the inputs the operator added to catalog
3
+ * models (`model.inputs`, below).
4
+ *
2
5
  * The Tenant-level fixture model: Tenant setting `model.fixture`, seeded with
3
6
  * `fixtureModel: true` (`PUT /v1/tenant/config/seed`, Host feature `tenant-fixture-model`).
4
7
  * A Tenant with it answers every model call with the Runtime's deterministic fixture model
@@ -8,9 +11,20 @@
8
11
  * The setting is read once per advance, in a transaction the advance makes anyway, so a Tenant
9
12
  * seeded on another node takes it at its next advance.
10
13
  */
14
+ import { type ModelInput } from "@nylorun/core/contracts";
11
15
  import type { Tx } from "../store/types.js";
12
16
  export declare const FIXTURE_MODEL_SETTING = "model.fixture";
13
17
  /** Whether the Tenant uses the fixture model. */
14
18
  export declare function usesFixtureModel(t: Tx): Promise<boolean>;
15
19
  /** Seeds the setting if absent. Returns whether it was inserted. */
16
20
  export declare function seedFixtureModel(t: Tx): Promise<boolean>;
21
+ /**
22
+ * The inputs the operator added to catalog models (protocol 12, `PUT /v1/tenant/models/inputs`):
23
+ * Tenant setting `model.inputs`, JSON `{ "<provider>/<model>": ModelInput[] }`. A custom
24
+ * endpoint keeps its own in its `settings.inputs` instead.
25
+ */
26
+ export declare const MODEL_INPUTS_SETTING = "model.inputs";
27
+ /** The added inputs by `<provider>/<model>`. Bad JSON, keys and inputs are left out: never throws. */
28
+ export declare function readAddedInputs(t: Pick<Tx, "getSetting">): Promise<Record<string, ModelInput[]>>;
29
+ /** Sets the inputs added to `<provider>/<model>`; an empty list removes its entry. */
30
+ export declare function writeAddedInputs(t: Pick<Tx, "getSetting" | "putSetting">, key: string, inputs: readonly ModelInput[]): Promise<void>;