@nylorun/runtime 0.24.0-beta → 0.25.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 (157) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +10 -2
  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.js +13 -2
  20. package/dist/api/http/body.d.ts +3 -3
  21. package/dist/api/http/body.js +7 -7
  22. package/dist/api/http/routes/environment-manifests.d.ts +9 -0
  23. package/dist/api/http/routes/environment-manifests.js +118 -0
  24. package/dist/api/http/routes/environments.js +61 -12
  25. package/dist/api/http/routes/reads.d.ts +52 -3
  26. package/dist/api/http/routes/sessions.js +13 -3
  27. package/dist/api/http/routes/tenant.js +35 -7
  28. package/dist/api/inbound-files.d.ts +65 -0
  29. package/dist/api/inbound-files.js +115 -0
  30. package/dist/api/openapi.js +18 -3
  31. package/dist/artifacts/disk.js +20 -14
  32. package/dist/artifacts/media-types.d.ts +15 -0
  33. package/dist/artifacts/media-types.js +56 -4
  34. package/dist/artifacts/parts.d.ts +12 -3
  35. package/dist/artifacts/parts.js +15 -4
  36. package/dist/artifacts/workspace.d.ts +4 -4
  37. package/dist/artifacts/workspace.js +6 -18
  38. package/dist/core/flow-host.d.ts +1 -1
  39. package/dist/core/flow-host.js +2 -2
  40. package/dist/core/sandbox-routes.js +2 -2
  41. package/dist/core/turn-manifest.d.ts +3 -2
  42. package/dist/core/turn-manifest.js +29 -5
  43. package/dist/gates/egress-credentials.js +1 -1
  44. package/dist/gates/egress.d.ts +13 -19
  45. package/dist/gates/egress.js +30 -29
  46. package/dist/gates/http-tool.d.ts +3 -4
  47. package/dist/gates/http-tool.js +8 -12
  48. package/dist/gates/journal.js +0 -1
  49. package/dist/gates/mcp-handler.js +2 -3
  50. package/dist/gates/policy.js +1 -3
  51. package/dist/gates/tool-contract.d.ts +1 -1
  52. package/dist/harness/calls.d.ts +2 -2
  53. package/dist/harness/calls.js +9 -12
  54. package/dist/harness/main.js +7 -1
  55. package/dist/harness/pod.d.ts +6 -1
  56. package/dist/harness/pod.js +5 -0
  57. package/dist/harness/service.js +5 -5
  58. package/dist/harness/setup.d.ts +2 -0
  59. package/dist/harness/setup.js +25 -1
  60. package/dist/harness-api/record.d.ts +1 -1
  61. package/dist/harness-api/record.js +13 -13
  62. package/dist/harness-api/server.d.ts +27 -4
  63. package/dist/harness-api/server.js +41 -8
  64. package/dist/harness-api/tool-journal.d.ts +17 -8
  65. package/dist/harness-api/tool-journal.js +41 -11
  66. package/dist/harness-api/workspace.d.ts +7 -5
  67. package/dist/harness-api/workspace.js +25 -19
  68. package/dist/host/gates.js +2 -2
  69. package/dist/host/stack-config.js +1 -1
  70. package/dist/keys/keys.d.ts +9 -2
  71. package/dist/keys/keys.js +4 -1
  72. package/dist/management-openapi.json +366 -61
  73. package/dist/mcp/connect.d.ts +1 -2
  74. package/dist/mcp/connect.js +1 -4
  75. package/dist/mcp/pool.d.ts +1 -4
  76. package/dist/mcp/pool.js +13 -27
  77. package/dist/mcp/preview.js +0 -1
  78. package/dist/mcp/snapshot.d.ts +11 -13
  79. package/dist/mcp/snapshot.js +27 -38
  80. package/dist/model/capabilities.d.ts +26 -0
  81. package/dist/model/capabilities.js +44 -0
  82. package/dist/model/catalog.d.ts +34 -3
  83. package/dist/model/catalog.js +38 -2
  84. package/dist/model/models.d.ts +2 -0
  85. package/dist/model/models.js +1 -1
  86. package/dist/model/native-blocks.d.ts +35 -0
  87. package/dist/model/native-blocks.js +170 -0
  88. package/dist/model/pi-model.js +106 -94
  89. package/dist/openapi.json +9554 -5104
  90. package/dist/record/environment.js +1 -1
  91. package/dist/sandbox/join.js +4 -0
  92. package/dist/sandbox/manager.d.ts +32 -14
  93. package/dist/sandbox/manager.js +132 -93
  94. package/dist/sandbox/placement.d.ts +1 -1
  95. package/dist/sandbox/placement.js +2 -2
  96. package/dist/sandbox/pods/client.d.ts +3 -1
  97. package/dist/sandbox/pods/lifecycle.d.ts +8 -0
  98. package/dist/sandbox/pods/lifecycle.js +15 -0
  99. package/dist/sandbox/pods/spec.js +2 -1
  100. package/dist/sandbox/resolve.d.ts +5 -3
  101. package/dist/sandbox/resolve.js +9 -2
  102. package/dist/sandbox/runtime-tools.d.ts +9 -0
  103. package/dist/sandbox/runtime-tools.js +16 -0
  104. package/dist/sandbox/session-sandbox.d.ts +4 -6
  105. package/dist/sandbox/session-sandbox.js +47 -94
  106. package/dist/sandbox/setup.d.ts +4 -1
  107. package/dist/sandbox/setup.js +46 -9
  108. package/dist/sandbox/share.d.ts +12 -7
  109. package/dist/sandbox/share.js +27 -25
  110. package/dist/sandbox/skills.d.ts +8 -6
  111. package/dist/sandbox/skills.js +24 -23
  112. package/dist/store/postgres/drizzle/0024_flat_manifest.sql +7 -0
  113. package/dist/store/postgres/drizzle/0025_environment_manifests.sql +14 -0
  114. package/dist/store/postgres/drizzle/0026_environment_forks.sql +1 -0
  115. package/dist/store/postgres/drizzle/0027_environment_image_digest.sql +1 -0
  116. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  117. package/dist/store/postgres/reads.js +8 -2
  118. package/dist/store/postgres/schema.d.ts +217 -0
  119. package/dist/store/postgres/schema.js +36 -2
  120. package/dist/store/postgres/store.js +79 -4
  121. package/dist/store/types.d.ts +68 -14
  122. package/dist/tenant/advance.js +3 -0
  123. package/dist/tenant/artifact-tool.d.ts +1 -1
  124. package/dist/tenant/artifact-tool.js +43 -22
  125. package/dist/tenant/commands.js +24 -4
  126. package/dist/tenant/context.d.ts +10 -4
  127. package/dist/tenant/effects.js +7 -9
  128. package/dist/tenant/environment-manifests.d.ts +57 -0
  129. package/dist/tenant/environment-manifests.js +200 -0
  130. package/dist/tenant/environments.d.ts +54 -47
  131. package/dist/tenant/environments.js +304 -83
  132. package/dist/tenant/model-setting.d.ts +14 -0
  133. package/dist/tenant/model-setting.js +51 -0
  134. package/dist/tenant/sessions.d.ts +5 -1
  135. package/dist/tenant/sessions.js +45 -12
  136. package/dist/tenant/skill-tool.d.ts +3 -3
  137. package/dist/tenant/skill-tool.js +6 -11
  138. package/dist/tenant/sweep.js +1 -1
  139. package/dist/tenant/tool-results.js +1 -1
  140. package/dist/tenant/tool-search.d.ts +7 -11
  141. package/dist/tenant/tool-search.js +8 -5
  142. package/dist/tenant/transcript.d.ts +51 -32
  143. package/dist/tenant/transcript.js +2 -1
  144. package/dist/traces/ids.d.ts +0 -1
  145. package/dist/traces/ids.js +0 -4
  146. package/dist/traces/map.d.ts +2 -2
  147. package/dist/traces/map.js +1 -29
  148. package/dist/vault/coverage.js +11 -13
  149. package/dist/vault/host-model.d.ts +4 -1
  150. package/dist/vault/host-model.js +10 -2
  151. package/dist/vault/service.d.ts +49 -4
  152. package/dist/vault/service.js +153 -6
  153. package/dist/version.d.ts +1 -1
  154. package/dist/version.js +1 -1
  155. package/package.json +3 -3
  156. package/dist/sandbox/capability.d.ts +0 -3
  157. package/dist/sandbox/capability.js +0 -10
@@ -1,14 +1,15 @@
1
1
  import { z } from "zod";
2
- import { ModelUsageQuerySchema, PutModelBudgetsRequestSchema, PutToolPolicyRequestSchema, PutHostModelRequestSchema, PutTenantEnvironmentsRequestSchema, ResetTenantRequestSchema, SeedTenantConfigRequestSchema, SelectHostModelRequestSchema, } from "@nylorun/core/contracts";
3
- import { HostModelCatalog, HostModelView, ListProvidersResponse, ModelBudgets, ModelUsageTotals, PutModelBudgetsRequest, PutToolPolicyRequest, ToolPolicy, PutHostModelRequest, PutTenantEnvironmentsRequest, ResetTenantRequest, ResetTenantResponse, SeedTenantConfigRequest, SeedTenantConfigResponse, SelectHostModelRequest, TenantSandboxView, TenantStatus, } from "../../components.js";
2
+ import { ModelUsageQuerySchema, PutModelBudgetsRequestSchema, PutToolPolicyRequestSchema, PutModelInputsRequestSchema, PutHostModelRequestSchema, PutTenantEnvironmentsRequestSchema, ResetTenantRequestSchema, SeedTenantConfigRequestSchema, SelectHostModelRequestSchema, } from "@nylorun/core/contracts";
3
+ import { HostModelCatalog, HostModelView, ListProvidersResponse, ModelBudgets, ModelInputsView, ModelUsageTotals, PutModelBudgetsRequest, PutToolPolicyRequest, PutModelInputsRequest, ToolPolicy, PutHostModelRequest, PutTenantEnvironmentsRequest, ResetTenantRequest, ResetTenantResponse, SeedTenantConfigRequest, SeedTenantConfigResponse, SelectHostModelRequest, TenantSandboxView, TenantStatus, } from "../../components.js";
4
4
  import { periodStart } from "../../../gates/meter.js";
5
5
  import { readToolPolicy, TOOL_POLICY_SETTING } from "../../../gates/policy.js";
6
- import { hostModelCatalog } from "../../../model/catalog.js";
6
+ import { addableInputs, capabilitiesOf, inputsError } from "../../../model/capabilities.js";
7
+ import { catalogModel, hostModelCatalog } from "../../../model/catalog.js";
7
8
  import { sandboxConfigErrors } from "../../../sandbox/resolve.js";
8
9
  import { effectiveSandboxConfig, readSandboxConfig, writeSandboxConfig, } from "../../../sandbox/tenant-config.js";
9
10
  import { fail } from "../../../tenant/http.js";
10
11
  import { clearObservers } from "../../../tenant/session-streams.js";
11
- import { usesFixtureModel } from "../../../tenant/model-setting.js";
12
+ import { readAddedInputs, usesFixtureModel, writeAddedInputs } from "../../../tenant/model-setting.js";
12
13
  import { resetTenant } from "../../../tenant/reset.js";
13
14
  import { clearWork, drain } from "../../../tenant/scheduler.js";
14
15
  import { buildTenantStatus, harnessStatusOf, seedTenantConfig } from "../../../tenant/status.js";
@@ -150,10 +151,37 @@ export function tenantRoutes(api) {
150
151
  path: "/v1/tenant/models",
151
152
  tags: ["Tenant"],
152
153
  summary: "List model providers and models",
153
- responses: { 200: json(HostModelCatalog, "Provider and model names") },
154
+ description: "Each model says what it reads (`inputs`: its catalog's inputs and those the operator added) and what may be added (`addableInputs`).",
155
+ responses: { 200: json(HostModelCatalog, "Provider and model names, and their inputs") },
154
156
  }, async (c) => {
155
- c.env.tenant;
156
- return jsonResponse(200, hostModelCatalog());
157
+ const added = await c.env.tenant.store.tx((t) => readAddedInputs(t));
158
+ return jsonResponse(200, hostModelCatalog(added));
159
+ });
160
+ tenantRoute(api, MANAGEMENT, {
161
+ method: "put",
162
+ path: "/v1/tenant/models/inputs",
163
+ tags: ["Tenant"],
164
+ summary: "Set what a catalog model reads",
165
+ description: "Replaces the inputs the operator added to one catalog model (Tenant setting `model.inputs`): only those in its `addableInputs`, which its provider API carries. Its catalog's own inputs always stay; an empty list removes the additions. A message naming a file the Tenant's model does not read is refused with `422 attachment_unsupported`. A custom endpoint sets `settings.inputs` on `PUT /v1/tenant/model` instead.",
166
+ request: { body: body(PutModelInputsRequest) },
167
+ responses: { 200: json(ModelInputsView, "What the model reads now") },
168
+ }, async (c) => {
169
+ const { provider, model, inputs } = PutModelInputsRequestSchema.parse(await readJson(c.req.raw));
170
+ if (provider === "custom")
171
+ fail(400, "A custom endpoint's inputs are its settings: set settings.inputs on PUT /v1/tenant/model.");
172
+ const found = catalogModel(provider, model);
173
+ if (!found)
174
+ fail(400, `Unknown model ${provider}/${model}.`);
175
+ const refused = inputsError(found, inputs);
176
+ if (refused)
177
+ fail(400, refused);
178
+ const added = inputs.filter((input) => addableInputs(found).includes(input));
179
+ await c.env.tenant.store.tx((t) => writeAddedInputs(t, `${provider}/${model}`, added));
180
+ return jsonResponse(200, {
181
+ provider,
182
+ model,
183
+ inputs: capabilitiesOf(found, added).inputs,
184
+ });
157
185
  });
158
186
  tenantRoute(api, MANAGEMENT, {
159
187
  method: "get",
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Files that arrive inside a message (multimodal messages, M7): the AG-UI and A2A endpoints take
3
+ * a file's bytes inline (base64), store them as an artifact of the message's session, and send
4
+ * the message as `parts` through the session's normal command path, so every check a message
5
+ * with file parts meets applies. A file by URL is refused: the Runtime fetches nothing.
6
+ *
7
+ * A retried message (the same idempotency key) reuses the files the first one stored rather than
8
+ * storing them again, so it replays the first turn instead of conflicting with it. A message the
9
+ * Runtime refuses takes the files it stored with it (`discardFiles`).
10
+ */
11
+ import type { MessagePart } from "@nylorun/core/contracts";
12
+ import { type ArtifactAccess } from "../artifacts/service.js";
13
+ import type { TenantContext } from "../tenant/context.js";
14
+ /** The largest file a message may carry inline, decoded. */
15
+ export declare const INLINE_FILE_MAX_BYTES: number;
16
+ /**
17
+ * The request body of an endpoint that takes inline files: one file at the cap, base64-encoded
18
+ * (a third larger), and the rest of the request.
19
+ */
20
+ export declare const INLINE_BODY_MAX_BYTES: number;
21
+ /** A file sent inline: its name, declared media type and base64 bytes. */
22
+ export interface InlineFile {
23
+ readonly name: string;
24
+ readonly mediaType: string;
25
+ /** The bytes, base64 (standard or URL-safe alphabet). */
26
+ readonly data: string;
27
+ }
28
+ /** One part of an inbound message: text, a file sent inline, or an artifact the Runtime holds. */
29
+ export type InboundPart = {
30
+ readonly type: "text";
31
+ readonly text: string;
32
+ } | ({
33
+ readonly type: "inline";
34
+ } & InlineFile) | {
35
+ readonly type: "artifact";
36
+ readonly artifactId: string;
37
+ };
38
+ /** The 400 for a file sent by URL. */
39
+ export declare function refuseUrl(): never;
40
+ /** A name for a file its client did not name: its place in the message and its type's extension. */
41
+ export declare function inlineFileName(mediaType: string, index: number): string;
42
+ /**
43
+ * Stores `file` as a new artifact of `sessionId`, within the inline cap and the Tenant's
44
+ * artifact limits: `400` for bad base64 or a bad media type, `413 limit_exceeded` past a limit.
45
+ */
46
+ export declare function storeInlineFile(ctx: TenantContext, sessionId: string, file: InlineFile, access: ArtifactAccess): Promise<{
47
+ artifactId: string;
48
+ version: number;
49
+ }>;
50
+ /**
51
+ * The parts of the message the session already took under `idempotencyKey`, when it took one
52
+ * with parts: a retry sends the same files again.
53
+ */
54
+ export declare function priorParts(ctx: TenantContext, sessionId: string, idempotencyKey: string): Promise<readonly MessagePart[] | undefined>;
55
+ /**
56
+ * A message's parts, its inline files stored as artifacts of `sessionId` (or, on a retry, the
57
+ * first message's files in their place, by position). `stored` lists the artifacts this call
58
+ * created, for `discardFiles` when the message is refused.
59
+ */
60
+ export declare function messageParts(ctx: TenantContext, sessionId: string, idempotencyKey: string, inbound: readonly InboundPart[], access: ArtifactAccess): Promise<{
61
+ parts: MessagePart[];
62
+ stored: string[];
63
+ }>;
64
+ /** Deletes the artifacts a refused message stored; one already gone is no error. */
65
+ export declare function discardFiles(ctx: TenantContext, artifactIds: readonly string[], access: ArtifactAccess): Promise<void>;
@@ -0,0 +1,115 @@
1
+ import { MAX_MESSAGE_PARTS } from "@nylorun/core/contracts";
2
+ import { commandKey } from "../core/flow-host.js";
3
+ import { extensionFor, normalizeContentType } from "../artifacts/media-types.js";
4
+ import { deleteArtifact, uploadArtifact } from "../artifacts/service.js";
5
+ import { fail } from "../tenant/http.js";
6
+ /** The largest file a message may carry inline, decoded. */
7
+ export const INLINE_FILE_MAX_BYTES = 20 * 1024 * 1024;
8
+ /**
9
+ * The request body of an endpoint that takes inline files: one file at the cap, base64-encoded
10
+ * (a third larger), and the rest of the request.
11
+ */
12
+ export const INLINE_BODY_MAX_BYTES = 28 * 1024 * 1024;
13
+ const BASE64 = /^[A-Za-z0-9+/_-]*={0,2}$/;
14
+ /** The 400 for a file sent by URL. */
15
+ export function refuseUrl() {
16
+ return fail(400, "File URLs are not fetched yet; send the bytes inline", {
17
+ code: "invalid_request",
18
+ });
19
+ }
20
+ /** A name for a file its client did not name: its place in the message and its type's extension. */
21
+ export function inlineFileName(mediaType, index) {
22
+ const extension = extensionFor(mediaType);
23
+ return `file-${index + 1}${extension ? `.${extension}` : ""}`;
24
+ }
25
+ /**
26
+ * Stores `file` as a new artifact of `sessionId`, within the inline cap and the Tenant's
27
+ * artifact limits: `400` for bad base64 or a bad media type, `413 limit_exceeded` past a limit.
28
+ */
29
+ export async function storeInlineFile(ctx, sessionId, file, access) {
30
+ const data = file.data.replace(/\s+/gu, "");
31
+ if (!BASE64.test(data) || data.length % 4 === 1)
32
+ fail(400, `File "${file.name}" is not valid base64`, { code: "invalid_request" });
33
+ // Three bytes per four characters, before decoding a byte.
34
+ if (Math.floor((data.length * 3) / 4) > INLINE_FILE_MAX_BYTES + 2)
35
+ fail(413, `A file sent inline may hold at most ${INLINE_FILE_MAX_BYTES} bytes`, {
36
+ code: "limit_exceeded",
37
+ details: { limitBytes: INLINE_FILE_MAX_BYTES },
38
+ });
39
+ // Node's base64 decoder reads both alphabets.
40
+ const bytes = Buffer.from(data, "base64");
41
+ if (bytes.byteLength > INLINE_FILE_MAX_BYTES)
42
+ fail(413, `A file sent inline may hold at most ${INLINE_FILE_MAX_BYTES} bytes`, {
43
+ code: "limit_exceeded",
44
+ details: { limitBytes: INLINE_FILE_MAX_BYTES },
45
+ });
46
+ if (bytes.byteLength === 0)
47
+ fail(400, `File "${file.name}" is empty`, { code: "invalid_request" });
48
+ const contentType = normalizeContentType(file.mediaType) ??
49
+ fail(400, `File "${file.name}" has no valid media type`, { code: "invalid_request" });
50
+ const { artifact, version } = await uploadArtifact(ctx, {
51
+ name: file.name,
52
+ contentType,
53
+ sessionId,
54
+ declaredBytes: bytes.byteLength,
55
+ source: "upload",
56
+ }, new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), access);
57
+ return { artifactId: artifact.artifactId, version: version.version };
58
+ }
59
+ /**
60
+ * The parts of the message the session already took under `idempotencyKey`, when it took one
61
+ * with parts: a retry sends the same files again.
62
+ */
63
+ export async function priorParts(ctx, sessionId, idempotencyKey) {
64
+ const prior = await ctx.store.tx((t) => t.get("commands", commandKey(sessionId, idempotencyKey)));
65
+ return prior?.command.type === "message" && Array.isArray(prior.command.parts)
66
+ ? prior.command.parts
67
+ : undefined;
68
+ }
69
+ /**
70
+ * A message's parts, its inline files stored as artifacts of `sessionId` (or, on a retry, the
71
+ * first message's files in their place, by position). `stored` lists the artifacts this call
72
+ * created, for `discardFiles` when the message is refused.
73
+ */
74
+ export async function messageParts(ctx, sessionId, idempotencyKey, inbound, access) {
75
+ const parts = inbound.filter((part) => part.type !== "text" || part.text !== "");
76
+ if (parts.length > MAX_MESSAGE_PARTS)
77
+ fail(400, `A message may carry at most ${MAX_MESSAGE_PARTS} parts`, { code: "invalid_request" });
78
+ const prior = parts.some((part) => part.type === "inline")
79
+ ? (await priorParts(ctx, sessionId, idempotencyKey))?.filter((part) => part.type === "file")
80
+ : undefined;
81
+ const out = [];
82
+ const stored = [];
83
+ let files = 0;
84
+ try {
85
+ for (const part of parts) {
86
+ if (part.type === "text") {
87
+ out.push({ type: "text", text: part.text });
88
+ continue;
89
+ }
90
+ const index = files++;
91
+ if (part.type === "artifact") {
92
+ out.push({ type: "file", artifactId: part.artifactId });
93
+ continue;
94
+ }
95
+ const again = prior?.[index];
96
+ if (again) {
97
+ out.push(again);
98
+ continue;
99
+ }
100
+ const file = await storeInlineFile(ctx, sessionId, part, access);
101
+ stored.push(file.artifactId);
102
+ out.push({ type: "file", artifactId: file.artifactId, version: file.version });
103
+ }
104
+ }
105
+ catch (error) {
106
+ await discardFiles(ctx, stored, access);
107
+ throw error;
108
+ }
109
+ return { parts: out, stored };
110
+ }
111
+ /** Deletes the artifacts a refused message stored; one already gone is no error. */
112
+ export async function discardFiles(ctx, artifactIds, access) {
113
+ for (const id of artifactIds)
114
+ await deleteArtifact(ctx, id, access).catch(() => undefined);
115
+ }
@@ -160,7 +160,7 @@ const RUNTIME_TAGS = [
160
160
  {
161
161
  name: "Environments",
162
162
  group: "Environments",
163
- description: "Where sessions run: one sandbox (a virtual workspace, a pod with its own volume, or none) with a recipe that can change, and one vault of secrets for its shell, MCP servers and HTTP tools. Create, find or change one, watch its lifecycle, stop, reset or delete it, and write its secrets. In `@nylorun/agents`.",
163
+ description: "Where sessions run: one sandbox (a virtual workspace, a pod with its own volume, or none) made from an environment manifest or inline fields and never changed after that, and one vault of secrets for its shell, MCP servers and HTTP tools. Create or find one, watch its lifecycle, stop, reset or delete it, and write its secrets. In `@nylorun/agents`.",
164
164
  operations: [
165
165
  "PUT /v1/environments/{environmentId}",
166
166
  "GET /v1/environments",
@@ -175,10 +175,24 @@ const RUNTIME_TAGS = [
175
175
  "DELETE /v1/environments/{environmentId}/secrets/{name}",
176
176
  "POST /v1/environments/{environmentId}/snapshot",
177
177
  "POST /v1/environments/{environmentId}/restore",
178
+ "POST /v1/environments/{environmentId}/fork",
178
179
  "GET /v1/environments/{environmentId}/versions",
179
180
  "DELETE /v1/environments/{environmentId}/versions/{version}",
180
181
  ],
181
182
  },
183
+ {
184
+ name: "Environment manifests",
185
+ group: "Environments",
186
+ description: "Named, versioned definitions of a kind of Environment, saved like agents (protocol 14). Save one with a plan first (`?dryRun=true`) and `If-Match`; an Environment is made from one version and keeps it, so a save changes no Environment. In `@nylorun/agents`.",
187
+ operations: [
188
+ "PUT /v1/environment-manifests/{name}",
189
+ "GET /v1/environment-manifests",
190
+ "GET /v1/environment-manifests/{name}",
191
+ "GET /v1/environment-manifests/{name}/versions",
192
+ "GET /v1/environment-manifests/{name}/versions/{version}",
193
+ "DELETE /v1/environment-manifests/{name}",
194
+ ],
195
+ },
182
196
  {
183
197
  name: "Artifacts",
184
198
  group: "Artifacts",
@@ -231,9 +245,10 @@ const MANAGEMENT_TAGS = [
231
245
  },
232
246
  {
233
247
  name: "Models",
234
- description: "Model providers, their credentials, the model the Tenant uses, what it spent, every model call for export, and the budgets that cap it. `admin.models`.",
248
+ description: "Model providers, their credentials, the model the Tenant uses, what each model reads, what it spent, every model call for export, and the budgets that cap it. `admin.models`.",
235
249
  operations: [
236
250
  "GET /v1/tenant/models",
251
+ "PUT /v1/tenant/models/inputs",
237
252
  "GET /v1/tenant/providers",
238
253
  "GET /v1/tenant/model",
239
254
  "PUT /v1/tenant/model",
@@ -377,7 +392,7 @@ function documents() {
377
392
  }),
378
393
  management: document(all, MANAGEMENT_TAGS, {
379
394
  title: "Nylorun Management API",
380
- description: "For operators: the Tenant's models, vaults, signing keys, settings and application keys, at `/v1/tenant/*` on the Tenant's URL, through `@nylorun/admin`. Every operation takes a management key, acting as itself: never for a subject and never from a browser. Management keys are issued only on the Tenant's machine (`nylorun key put <id> --management`) or from `NYLORUN_MANAGEMENT_KEY_FILE`.",
395
+ description: "For operators: the Tenant's models, vaults, signing keys, settings and application keys, at `/v1/tenant/*` on the Tenant's URL, through `@nylorun/admin`. Every operation takes a management key, acting as itself: never for a subject and never from a browser. Management keys are issued only on the Tenant's machine (`nylorun tenant keys put <id> --management`) or from `NYLORUN_MANAGEMENT_KEY_FILE`.",
381
396
  }),
382
397
  };
383
398
  return built;
@@ -13,8 +13,9 @@
13
13
  * version back: directories, files (large ones in appended ranges), then links.
14
14
  * - **Restore** puts a version back: on a virtual Environment at once; on a pod as a reset whose
15
15
  * new volume is seeded before any turn (`disk_seed`, run once the new pod's engine connects).
16
- * **Fork** (`from` on a create) and the **recovery of a lost volume** (from the latest version)
17
- * are seeds too.
16
+ * **Fork** (`from` on a create, and `POST .../fork`) and the **recovery of a lost volume** (from
17
+ * the latest version) are seeds too. A fork onto other setup (Q17) keeps its seed, `written`,
18
+ * until the engine has run that setup on the written disk; turns wait for both.
18
19
  *
19
20
  * Nothing here runs inside a transaction but the lease and the commit. No event type is new:
20
21
  * a restore or recovery is `environment.reset` with `reason` and `from`.
@@ -23,7 +24,6 @@ import { createHash, randomUUID } from "node:crypto";
23
24
  import { newArtifactId } from "@nylorun/core/compatibility";
24
25
  import { FOLDER_CONTENT_TYPE, FOLDER_MANIFEST_FORMAT_V2, } from "@nylorun/core/contracts";
25
26
  import { sandboxSpecOf } from "../sandbox/resolve.js";
26
- import { capabilityForSandbox } from "../sandbox/share.js";
27
27
  import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
28
28
  import { assertReaches, diskHeld, liveEnvironment, newVolume, SEED_LEASE_MS, wakePod, } from "../tenant/environments.js";
29
29
  import { fail } from "../tenant/http.js";
@@ -51,15 +51,17 @@ async function diskTarget(ctx, environment) {
51
51
  ref: {
52
52
  id: `disk:${environment.id}`,
53
53
  activeTurnId: null,
54
- manifest: { id: "disk", capabilities: [] },
54
+ manifest: { manifestSchemaVersion: 7, id: "disk" },
55
55
  environmentId: environment.id,
56
+ // Not through a host a restore or a lost volume retired: its connection outlives the commit.
57
+ ...(environment.pod === undefined ? {} : { hostEpoch: environment.pod.hostEpoch }),
56
58
  },
57
- capability: capabilityForSandbox(spec),
59
+ spec,
58
60
  };
59
61
  }
60
62
  /** One disk operation through the workspace, or a `409 environment_unavailable`. */
61
63
  async function diskOp(ctx, target, op) {
62
- const answer = await ctx.sandbox.disk(target.ref, target.capability, op, new AbortController().signal);
64
+ const answer = await ctx.sandbox.disk(target.ref, target.spec, op, new AbortController().signal);
63
65
  if ("code" in answer)
64
66
  return fail(409, `The disk could not be reached: ${answer.message}`, { code: "environment_unavailable" });
65
67
  return answer;
@@ -111,10 +113,13 @@ function heartbeat(ctx, id, ms = LEASE_MS) {
111
113
  const environment = await t.environment(id, { lock: true });
112
114
  if (!environment)
113
115
  return;
114
- const clearSeed = options.seed !== undefined &&
115
- environment.diskSeed?.artifactId === options.seed.artifactId &&
116
- environment.diskSeed.version === options.seed.version;
117
- await t.updateEnvironment(id, { diskOp: null, ...(clearSeed ? { diskSeed: null } : {}) }, new Date().toISOString());
116
+ const seed = environment.diskSeed;
117
+ const written = options.seed !== undefined &&
118
+ seed?.artifactId === options.seed.artifactId &&
119
+ seed.version === options.seed.version;
120
+ // A seed with setup stays, written, until the engine reports the setup done (Q17).
121
+ const next = !written ? {} : seed.setup ? { diskSeed: { ...seed, written: true } } : { diskSeed: null };
122
+ await t.updateEnvironment(id, { diskOp: null, ...next }, new Date().toISOString());
118
123
  });
119
124
  },
120
125
  };
@@ -437,11 +442,12 @@ export async function runPendingSeed(ctx, id) {
437
442
  try {
438
443
  const environment = await ctx.store.tx((t) => liveEnvironment(t, id));
439
444
  const seed = environment?.diskSeed;
440
- if (!environment || !seed)
445
+ if (!environment || !seed || seed.written)
441
446
  return;
442
447
  if (environment.kind === "pod") {
443
448
  const deadline = Date.now() + ENGINE_WAIT_MS;
444
- while (!ctx.harness.hosting(id)) {
449
+ // The engine on the new volume: the old one's connection may still be open.
450
+ while (!ctx.harness.hosting(id, environment.pod?.hostEpoch)) {
445
451
  if (Date.now() > deadline)
446
452
  return;
447
453
  await new Promise((resolve) => setTimeout(resolve, 250));
@@ -471,9 +477,9 @@ export async function runPendingSeed(ctx, id) {
471
477
  }
472
478
  /** Starts the pending seeds whose engine is connected and whose lease lapsed (Tenant sweep). */
473
479
  export async function sweepPendingSeeds(ctx) {
474
- const pending = (await ctx.store.tx((t) => t.listEnvironments())).filter((environment) => environment.diskSeed !== undefined && environment.pod?.desired !== "deleted");
480
+ const pending = (await ctx.store.tx((t) => t.listEnvironments())).filter((environment) => environment.diskSeed !== undefined && !environment.diskSeed.written && environment.pod?.desired !== "deleted");
475
481
  for (const environment of pending)
476
- if (environment.kind !== "pod" || ctx.harness.hosting(environment.id))
482
+ if (environment.kind !== "pod" || ctx.harness.hosting(environment.id, environment.pod?.hostEpoch))
477
483
  void runPendingSeed(ctx, environment.id);
478
484
  }
479
485
  /**
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Media types of file artifacts: what an upload may declare, what a name's extension implies,
3
+ * and which model input a file is (`modelInputOf`, protocol 12): text, an image, a PDF, an Office
4
+ * document, audio or video. A model reads a file only when its inputs include that one.
5
+ */
6
+ import type { ModelInput } from "@nylorun/core/contracts";
1
7
  /** Images the model reads as images. */
2
8
  export declare const MODEL_IMAGE_TYPES: readonly string[];
3
9
  /** The largest image or text file a model call carries inline. */
@@ -13,3 +19,12 @@ export declare function isModelImage(contentType: string): boolean;
13
19
  export declare function isText(contentType: string): boolean;
14
20
  /** The usual extension of a media type, for a name the Runtime gives a file; undefined if none. */
15
21
  export declare function extensionFor(contentType: string): string | undefined;
22
+ /** The model input a file of `contentType` is, or undefined for a type no model reads. */
23
+ export declare function modelInputOf(contentType: string): ModelInput | undefined;
24
+ /** An input, in a sentence: "PDFs", "Office documents"; "files of this type" for none. */
25
+ export declare function inputLabel(input: ModelInput | undefined): string;
26
+ /**
27
+ * Why a model that reads `inputs` cannot read a file of `contentType`, or undefined when it can.
28
+ * An image must also be PNG, JPEG, GIF or WebP.
29
+ */
30
+ export declare function unreadableReason(contentType: string, inputs: readonly ModelInput[]): string | undefined;
@@ -1,7 +1,3 @@
1
- /**
2
- * Media types of file artifacts: what an upload may declare, what a name's extension implies,
3
- * and how the model reads a file (an image as an image, text as text, anything else refused).
4
- */
5
1
  import { DEFAULT_CONTENT_TYPE } from "../blob/index.js";
6
2
  const MEDIA_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/;
7
3
  const BY_EXTENSION = {
@@ -12,6 +8,12 @@ const BY_EXTENSION = {
12
8
  webp: "image/webp",
13
9
  svg: "image/svg+xml",
14
10
  pdf: "application/pdf",
11
+ doc: "application/msword",
12
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
13
+ xls: "application/vnd.ms-excel",
14
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
15
+ ppt: "application/vnd.ms-powerpoint",
16
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
15
17
  txt: "text/plain",
16
18
  md: "text/markdown",
17
19
  csv: "text/csv",
@@ -83,3 +85,53 @@ export function extensionFor(contentType) {
83
85
  const type = essence(contentType);
84
86
  return Object.keys(BY_EXTENSION).find((extension) => BY_EXTENSION[extension] === type);
85
87
  }
88
+ /** Word, Excel and PowerPoint documents, old and Open XML. */
89
+ function isOffice(type) {
90
+ return (type === "application/msword" ||
91
+ type === "application/vnd.ms-excel" ||
92
+ type === "application/vnd.ms-powerpoint" ||
93
+ type.startsWith("application/vnd.openxmlformats-officedocument."));
94
+ }
95
+ /** The model input a file of `contentType` is, or undefined for a type no model reads. */
96
+ export function modelInputOf(contentType) {
97
+ const type = essence(contentType);
98
+ if (isText(type))
99
+ return "text";
100
+ if (type.startsWith("image/"))
101
+ return "image";
102
+ if (type === "application/pdf")
103
+ return "pdf";
104
+ if (isOffice(type))
105
+ return "office";
106
+ if (type.startsWith("audio/"))
107
+ return "audio";
108
+ if (type.startsWith("video/"))
109
+ return "video";
110
+ return undefined;
111
+ }
112
+ const LABELS = {
113
+ text: "text files",
114
+ image: "images",
115
+ pdf: "PDFs",
116
+ office: "Office documents",
117
+ audio: "audio",
118
+ video: "video",
119
+ };
120
+ /** An input, in a sentence: "PDFs", "Office documents"; "files of this type" for none. */
121
+ export function inputLabel(input) {
122
+ return input === undefined ? "files of this type" : LABELS[input];
123
+ }
124
+ /**
125
+ * Why a model that reads `inputs` cannot read a file of `contentType`, or undefined when it can.
126
+ * An image must also be PNG, JPEG, GIF or WebP.
127
+ */
128
+ export function unreadableReason(contentType, inputs) {
129
+ const input = modelInputOf(contentType);
130
+ if (input === undefined)
131
+ return `no model reads ${essence(contentType)} files`;
132
+ if (!inputs.includes(input))
133
+ return `this model does not read ${LABELS[input]}`;
134
+ if (input === "image" && !isModelImage(contentType))
135
+ return "the model reads PNG, JPEG, GIF and WebP images only";
136
+ return undefined;
137
+ }
@@ -5,7 +5,7 @@
5
5
  * The engine gets an opaque media part whose `reference` is `{ artifactId, version }`: model-gate
6
6
  * resolves it to bytes (`./files.ts`), and the transcript and the record never hold them.
7
7
  */
8
- import type { MessagePart } from "@nylorun/core/contracts";
8
+ import type { MessagePart, ModelInput } from "@nylorun/core/contracts";
9
9
  import type { JsonObject, JsonValue } from "@nylorun/core/define";
10
10
  import type { Tx } from "../store/types.js";
11
11
  import { type ArtifactAccess } from "./service.js";
@@ -24,11 +24,20 @@ export interface FileReference {
24
24
  readonly version: number;
25
25
  }
26
26
  export declare function fileReferenceOf(value: JsonValue | undefined): FileReference | undefined;
27
+ /** A file part the Tenant's model does not read (protocol 12, `attachment_unsupported`). */
28
+ export interface RefusedPart {
29
+ readonly artifactId: string;
30
+ readonly name: string;
31
+ readonly mediaType: string;
32
+ readonly reason: string;
33
+ }
27
34
  /**
28
35
  * The parts with each file pinned to a version: for the engine (`engine`) and for a workflow's
29
- * JSON input (`pinned`). Reads in the caller's transaction.
36
+ * JSON input (`pinned`). Reads in the caller's transaction. With `inputs`, what the Tenant's model
37
+ * reads, each file of a type it does not read is in `refused` (its type only: no size is checked).
30
38
  */
31
- export declare function resolveMessageParts(t: Tx, parts: readonly MessagePart[], sessionId: string, access: ArtifactAccess): Promise<{
39
+ export declare function resolveMessageParts(t: Tx, parts: readonly MessagePart[], sessionId: string, access: ArtifactAccess, inputs?: readonly ModelInput[]): Promise<{
32
40
  engine: EnginePart[];
33
41
  pinned: MessagePart[];
42
+ refused: RefusedPart[];
34
43
  }>;
@@ -1,4 +1,5 @@
1
1
  import { fail } from "../tenant/http.js";
2
+ import { unreadableReason } from "./media-types.js";
2
3
  import { readableArtifact, versionOf } from "./service.js";
3
4
  export function fileReferenceOf(value) {
4
5
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -10,11 +11,13 @@ export function fileReferenceOf(value) {
10
11
  }
11
12
  /**
12
13
  * The parts with each file pinned to a version: for the engine (`engine`) and for a workflow's
13
- * JSON input (`pinned`). Reads in the caller's transaction.
14
+ * JSON input (`pinned`). Reads in the caller's transaction. With `inputs`, what the Tenant's model
15
+ * reads, each file of a type it does not read is in `refused` (its type only: no size is checked).
14
16
  */
15
- export async function resolveMessageParts(t, parts, sessionId, access) {
17
+ export async function resolveMessageParts(t, parts, sessionId, access, inputs) {
16
18
  const engine = [];
17
19
  const pinned = [];
20
+ const refused = [];
18
21
  for (const part of parts) {
19
22
  if (part.type === "text") {
20
23
  engine.push({ type: "text", text: part.text });
@@ -27,12 +30,20 @@ export async function resolveMessageParts(t, parts, sessionId, access) {
27
30
  if (artifact.kind !== "file")
28
31
  fail(400, `Artifact ${artifact.id} is a folder; a file part names a file artifact`);
29
32
  const version = await versionOf(t, artifact, part.version);
33
+ const reason = inputs && unreadableReason(version.contentType, inputs);
34
+ if (reason)
35
+ refused.push({ artifactId: artifact.id, name: artifact.name, mediaType: version.contentType, reason });
30
36
  engine.push({
31
37
  type: "media",
32
38
  mediaType: version.contentType,
33
- reference: { artifactId: artifact.id, version: version.version, name: artifact.name },
39
+ reference: {
40
+ artifactId: artifact.id,
41
+ version: version.version,
42
+ name: artifact.name,
43
+ size: version.size,
44
+ },
34
45
  });
35
46
  pinned.push({ type: "file", artifactId: artifact.id, version: version.version });
36
47
  }
37
- return { engine, pinned };
48
+ return { engine, pinned, refused };
38
49
  }
@@ -3,15 +3,15 @@
3
3
  * export its outputs as a folder artifact. Two calls, a listing and one file's bytes, both the
4
4
  * claim of whoever holds the workspace: the Runtime stores what it is given without observing it.
5
5
  *
6
- * One implementation, `sandboxWorkspaceReader`, over the workspace capability (F6.2,
6
+ * One implementation, `sandboxWorkspaceReader`, over the workspace feature (F6.2,
7
7
  * `harness-api/workspace.ts`): the in-process `SandboxManager`, or the harness that serves the
8
8
  * Tenant's workspaces (`workspace.read`). F6.2 reimplements it over the Harness API's `workspace.read`, and F7.2's pod
9
9
  * sandboxes are read through the same seam, so the export never knows which kind of sandbox it
10
10
  * reads.
11
11
  */
12
- import { type AgentManifest } from "@nylorun/core/define";
12
+ import type { AgentManifest } from "@nylorun/core/define";
13
13
  import { type SandboxManager } from "../sandbox/manager.js";
14
- /** What the reader needs of a workspace: a `SandboxManager`, or the workspace capability. */
14
+ /** What the reader needs of a workspace: a `SandboxManager`, or the workspace feature. */
15
15
  export type WorkspaceFiles = Pick<SandboxManager, "listFiles" | "readBytes">;
16
16
  /** Whose workspace to read: the session whose turn ended, resolved to the workspace it uses. */
17
17
  export interface WorkspaceSession {
@@ -23,7 +23,7 @@ export interface WorkspaceSession {
23
23
  readonly ownerId: string;
24
24
  /** The sandbox resource the workspace belongs to, when the owner is attached to one. */
25
25
  readonly environmentId?: string;
26
- /** The session's pinned manifest: its sandbox capability carries the sandbox's spec. */
26
+ /** The session's pinned manifest: its `runtime` block carries the sandbox's spec. */
27
27
  readonly manifest: AgentManifest;
28
28
  }
29
29
  /** One regular file under the listed directory. */
@@ -1,25 +1,13 @@
1
- /**
2
- * The `WorkspaceReader` seam (F8.2): how core reads a session's sandbox workspace at turn end, to
3
- * export its outputs as a folder artifact. Two calls, a listing and one file's bytes, both the
4
- * claim of whoever holds the workspace: the Runtime stores what it is given without observing it.
5
- *
6
- * One implementation, `sandboxWorkspaceReader`, over the workspace capability (F6.2,
7
- * `harness-api/workspace.ts`): the in-process `SandboxManager`, or the harness that serves the
8
- * Tenant's workspaces (`workspace.read`). F6.2 reimplements it over the Harness API's `workspace.read`, and F7.2's pod
9
- * sandboxes are read through the same seam, so the export never knows which kind of sandbox it
10
- * reads.
11
- */
12
- import { SANDBOX_CAPABILITY_ID, } from "@nylorun/core/define";
13
- import { sandboxCapabilityOf } from "../sandbox/manager.js";
1
+ import { sandboxToolSpec } from "../sandbox/manager.js";
14
2
  import { SandboxFileTooLargeError } from "../sandbox/types.js";
15
3
  /** The reader over the workspaces, in each sandbox's queue of tool calls. */
16
4
  export function sandboxWorkspaceReader(sandbox) {
17
5
  const target = (session) => {
18
- const capability = sandboxCapabilityOf(session.manifest, SANDBOX_CAPABILITY_ID, "read");
19
- if (!capability)
6
+ const spec = sandboxToolSpec(session.manifest, "read");
7
+ if (!spec)
20
8
  return undefined;
21
9
  return {
22
- capability,
10
+ spec,
23
11
  ref: {
24
12
  id: session.ownerId,
25
13
  activeTurnId: session.turnId,
@@ -33,7 +21,7 @@ export function sandboxWorkspaceReader(sandbox) {
33
21
  const found = target(session);
34
22
  if (!found)
35
23
  return undefined;
36
- const listed = await sandbox.listFiles(found.ref, found.capability, dir, options.maxEntries, options.signal);
24
+ const listed = await sandbox.listFiles(found.ref, found.spec, dir, options.maxEntries, options.signal);
37
25
  if (listed.kind === "missing")
38
26
  return undefined;
39
27
  if (listed.kind === "failed")
@@ -44,7 +32,7 @@ export function sandboxWorkspaceReader(sandbox) {
44
32
  const found = target(session);
45
33
  if (!found)
46
34
  return undefined;
47
- const read = await sandbox.readBytes(found.ref, found.capability, path, options.maxBytes, options.signal);
35
+ const read = await sandbox.readBytes(found.ref, found.spec, path, options.maxBytes, options.signal);
48
36
  if (read.kind === "read")
49
37
  return read.bytes;
50
38
  if (read.kind === "missing")
@@ -16,7 +16,7 @@ export declare function deriveAgentEffectSessionId(workflowSessionId: string, pa
16
16
  readonly context?: Record<string, unknown>;
17
17
  }): string;
18
18
  export declare function isWorkflowManifest(manifest: unknown): manifest is WorkflowManifest;
19
- /** Flow tool-node effect: `tool` with path/key and no agent capabilityId. */
19
+ /** Flow tool-node effect: `tool` with path/key and no tool name (an agent's tool call names one). */
20
20
  export declare function isFlowToolEffect(request: HostEffect): boolean;
21
21
  export declare function isFlowEffect(request: HostEffect): boolean;
22
22
  /**