@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
@@ -82,16 +82,16 @@ export function startHarnessService(options) {
82
82
  if ("disk" in params) {
83
83
  // A disk version's read or write (R2d step 3).
84
84
  const call = params;
85
- const answer = await sandbox().disk(refOf(call.session), { id: "sandbox", type: "agent", sandbox: call.spec, tools: [] }, fromWireOp(call.disk), signal);
85
+ const answer = await sandbox().disk(refOf(call.session), call.spec, fromWireOp(call.disk), signal);
86
86
  return "code" in answer ? answer : toWireAnswer(answer);
87
87
  }
88
88
  if (method === "workspace.read" && "list" in params) {
89
89
  const call = params;
90
- return sandbox().listFiles(refOf(call.session), { id: "sandbox", type: "agent", sandbox: call.spec, tools: [] }, call.list.dir, call.list.maxEntries, signal);
90
+ return sandbox().listFiles(refOf(call.session), call.spec, call.list.dir, call.list.maxEntries, signal);
91
91
  }
92
92
  if (method === "workspace.read" && "bytes" in params) {
93
93
  const call = params;
94
- const read = await sandbox().readBytes(refOf(call.session), { id: "sandbox", type: "agent", sandbox: call.spec, tools: [] }, call.bytes.path, call.bytes.maxBytes, signal);
94
+ const read = await sandbox().readBytes(refOf(call.session), call.spec, call.bytes.path, call.bytes.maxBytes, signal);
95
95
  return read.kind === "read"
96
96
  ? { kind: "read", path: read.path, base64: Buffer.from(read.bytes).toString("base64") }
97
97
  : read;
@@ -99,7 +99,7 @@ export function startHarnessService(options) {
99
99
  const call = params;
100
100
  if (METHODS[call.tool] !== method)
101
101
  throw new HarnessApiError("invalid", `${method} does not run ${call.tool}`);
102
- return sandbox().run(refOf(call.session), { id: "sandbox", type: "agent", sandbox: call.spec, tools: [] }, call.tool, call.input, signal);
102
+ return sandbox().run(refOf(call.session), call.spec, call.tool, call.input, signal);
103
103
  }
104
104
  case "workspace.report":
105
105
  return sandbox().report();
@@ -138,7 +138,7 @@ export function startHarnessService(options) {
138
138
  // Our harness: its hello names our harness id (R4.1).
139
139
  name: DEFAULT_HARNESS,
140
140
  ...(options.version ? { version: options.version } : {}),
141
- capabilities: { workspace: { backends: backends.map((backend) => backend.name) } },
141
+ features: { workspace: { backends: backends.map((backend) => backend.name) } },
142
142
  onHello(answer) {
143
143
  tenantId = answer.tenantId;
144
144
  manager ??= new SandboxManager({
@@ -6,6 +6,8 @@ export interface DiskSetupOptions {
6
6
  readonly host: Pick<PodHost, "setup" | "setupState" | "reportSetup" | "setupFile"> & {
7
7
  /** The volume starts from a disk version (R2d step 3): it holds what setup made. */
8
8
  readonly seeded?: boolean;
9
+ /** A fork onto other setup (Q17): run it once core has written the version. */
10
+ readonly afterSeed?: boolean;
9
11
  };
10
12
  /** Runs one command as the sandbox tools do (`localExec`). */
11
13
  readonly exec: (request: ExecRequest, signal: AbortSignal) => Promise<ExecResult>;
@@ -8,12 +8,18 @@
8
8
  *
9
9
  * A failure is reported to core, which fails the pod and the turn waiting for it; the engine then
10
10
  * polls until a turn start asks the pod to run again, and runs the setup again from the start.
11
+ *
12
+ * A volume seeded from a disk version skips it (the version holds what setup made), except on a
13
+ * fork onto other setup (Q17): the engine connects first, waits until core has written the
14
+ * version, then runs the setup on it as above. Turns wait until it reports the setup done.
11
15
  */
12
16
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
13
17
  import { randomUUID } from "node:crypto";
14
18
  import { dirname, posix } from "node:path";
15
19
  /** How often a failed setup asks whether to run again. */
16
20
  export const SETUP_RETRY_POLL_MS = 5_000;
21
+ /** How often a fork's setup asks whether its disk version is written (Q17). */
22
+ const AFTER_SEED_POLL_MS = 1_000;
17
23
  /** The end of a failed command's output kept in its report. */
18
24
  const REPORT_TAIL = 4 * 1024;
19
25
  async function readText(path) {
@@ -89,7 +95,25 @@ async function runOnce(options, setup) {
89
95
  export async function prepareDisk(options) {
90
96
  let setup = options.host.setup;
91
97
  const volumeId = await readText(options.volumeFile);
92
- if (options.host.seeded) {
98
+ if (options.host.afterSeed) {
99
+ // A fork onto other setup (Q17): wait until core has written the disk version, then run it.
100
+ for (;;) {
101
+ if (options.signal?.aborted)
102
+ return;
103
+ const state = await options.host.setupState().catch(() => undefined);
104
+ if (state?.retry) {
105
+ setup = state.setup;
106
+ break;
107
+ }
108
+ await new Promise((resolve) => setTimeout(resolve, options.pollMs ?? AFTER_SEED_POLL_MS));
109
+ }
110
+ if (!setup) {
111
+ // Nothing to run: say so, so the fork's turns may start.
112
+ await options.host.reportSetup({ status: "ok" }).catch(() => undefined);
113
+ return;
114
+ }
115
+ }
116
+ else if (options.host.seeded) {
93
117
  // A volume seeded from a disk version: the version holds what setup made, so mark it done.
94
118
  if (volumeId) {
95
119
  const done = { volumeId, recipeVersion: setup?.recipeVersion ?? 0, status: "ok" };
@@ -27,7 +27,7 @@ export interface RecordScope {
27
27
  readonly ctx: TenantContext;
28
28
  readonly lease: Lease;
29
29
  readonly signal: AbortSignal;
30
- /** The tools the run's harness declared it runs itself (`hello` `capabilities.tools`, R4.2). */
30
+ /** The tools the run's harness declared it runs itself (`hello` `features.tools`, R4.2), by name. */
31
31
  readonly nativeTools?: ReadonlySet<string>;
32
32
  /**
33
33
  * Who reported what this scope records (R4.9): the run's harness, so the events derived from
@@ -18,9 +18,9 @@
18
18
  * row keeps its span's context (`traceparent`, R7.4), which the `execute` answer carries to the
19
19
  * harness and the Tool Gate reads with the row.
20
20
  */
21
- import { HARNESS_TOOLS_CAPABILITY, HarnessApiError, effectRequestHash, isEffectIdOf, } from "@nylorun/core/harness-api";
21
+ import { HarnessApiError, effectRequestHash, isEffectIdOf, } from "@nylorun/core/harness-api";
22
22
  import { isFlowEffect, isFlowToolEffect, recordVerdict, settleAgentEffect, } from "../core/flow-host.js";
23
- import { sandboxCapabilityOf } from "../sandbox/capability.js";
23
+ import { sandboxToolSpec } from "../sandbox/runtime-tools.js";
24
24
  import { abortOn, isReadArtifactCall, isSaveArtifactCall } from "../harness/calls.js";
25
25
  import { callReadArtifact, callSaveArtifact } from "../tenant/artifact-tool.js";
26
26
  import { mayNeedShaping, resultBytes, resultLimit, shapeToolOutcome, } from "../tenant/tool-results.js";
@@ -143,17 +143,17 @@ export async function recordIntent(scope, effect, requestHash) {
143
143
  (mcpToolOf(s.mcpSnapshot, request, turnManifestOf(s)) !== undefined ||
144
144
  // HTTP tools (R2 M3) cross the Tool Gate.
145
145
  isHttpToolCall(s.manifest, request) ||
146
- sandboxCapabilityOf(agentManifest, request.capabilityId, request.toolName) !== undefined ||
146
+ sandboxToolSpec(agentManifest, request.toolName) !== undefined ||
147
147
  // `save_artifact` (F8.1) runs beside the sandbox tools, as do `read_artifact` (R2b
148
148
  // C11) and the skill tools (R2 M4).
149
149
  isSaveArtifactCall(agentManifest, request) ||
150
150
  isReadArtifactCall(agentManifest, request) ||
151
151
  isSkillToolCall(agentManifest, request) ||
152
152
  // `tool_search` (R2b C10) reads the session's pinned MCP snapshot: core serves it.
153
- isToolSearchCall(agentManifest, request) ||
154
- // A tool the run's harness declared and runs itself (R4.2, Q3).
155
- (request.capabilityId === HARNESS_TOOLS_CAPABILITY &&
156
- scope.nativeTools?.has(request.toolName ?? "") === true)));
153
+ isToolSearchCall(s.mcpSnapshot, request) ||
154
+ // A tool the run's harness declared and runs itself (R4.2, Q3), by its name: the turn
155
+ // refuses a native tool whose name another tool of the agent's has (`harness-api/server.ts`).
156
+ (request.toolName !== undefined && scope.nativeTools?.has(request.toolName) === true)));
157
157
  if (!executed) {
158
158
  // A tool that would run the developer's code: refused at save (a turn's manifest only
159
159
  // removes tools), so this is a backstop, as is an MCP tool the turn disabled, which the
@@ -163,7 +163,7 @@ export async function recordIntent(scope, effect, requestHash) {
163
163
  request,
164
164
  requestHash,
165
165
  status: "invoking",
166
- ...toolKindOf(s, agentManifest, request),
166
+ ...toolKindOf(s, agentManifest, request, scope.nativeTools),
167
167
  attempts: 0,
168
168
  };
169
169
  const outcome = await settleToolRow(t, s, row, disabled ? disabledTool(request) : unrunnableTool(request), { writer: coreIn(scope) });
@@ -175,7 +175,7 @@ export async function recordIntent(scope, effect, requestHash) {
175
175
  requestHash,
176
176
  status: "invoking",
177
177
  ...(request.kind === "tool"
178
- ? { ...firstSend(toolKindOf(s, agentManifest, request)), ...traced() }
178
+ ? { ...firstSend(toolKindOf(s, agentManifest, request, scope.nativeTools)), ...traced() }
179
179
  : { startedAt: new Date().toISOString() }),
180
180
  };
181
181
  // The Tenant's tool policy decides an MCP or HTTP tool call as it is journaled (R5 step 2).
@@ -186,7 +186,7 @@ export async function recordIntent(scope, effect, requestHash) {
186
186
  }
187
187
  await t.put("effects", request.effectId, row);
188
188
  // `save_artifact` writes the Tenant's artifacts: core runs it, reading the file through the
189
- // workspace capability, wherever the sandbox is.
189
+ // workspace feature, wherever the sandbox is.
190
190
  if (request.kind === "tool" && isSaveArtifactCall(agentManifest, request))
191
191
  return "save";
192
192
  // `read_artifact` reads the session's artifacts: core serves it, with or without a sandbox.
@@ -195,7 +195,7 @@ export async function recordIntent(scope, effect, requestHash) {
195
195
  // The skill tools read the agent's definition files: core serves them.
196
196
  if (isSkillToolCall(agentManifest, request))
197
197
  return { skill: agentManifest };
198
- if (isToolSearchCall(agentManifest, request))
198
+ if (isToolSearchCall(s.mcpSnapshot, request))
199
199
  return "search";
200
200
  return { status: "execute", ...(row.traceparent ? { traceparent: row.traceparent } : {}) };
201
201
  });
@@ -413,8 +413,8 @@ async function shaped(scope, effectId, value) {
413
413
  if (!s || !effect || effect.status === "completed")
414
414
  return undefined;
415
415
  const request = effect.request;
416
- // An agent's tool; a flow's HTTP stage or verifier names no capability, and its output is data.
417
- if (request.kind !== "tool" || request.capabilityId === undefined)
416
+ // An agent's tool; a flow's HTTP stage or verifier names no tool, and its output is data.
417
+ if (request.kind !== "tool" || request.toolName === undefined)
418
418
  return undefined;
419
419
  const mcp = mcpToolOf(s.mcpSnapshot, request) !== undefined;
420
420
  if (!mcp && !isHttpToolCall(s.manifest, request))
@@ -59,6 +59,14 @@ export type RunEnd = {
59
59
  /** No harness took the run within `offerWaitMs`. */
60
60
  | {
61
61
  readonly kind: "unavailable";
62
+ }
63
+ /**
64
+ * The harness that would take the run cannot: a tool it runs itself (`features.tools`) has the
65
+ * name of a tool of the turn's agents. The turn fails with `message`.
66
+ */
67
+ | {
68
+ readonly kind: "refused";
69
+ readonly message: string;
62
70
  };
63
71
  export interface HarnessApiServer {
64
72
  /** Serves a harness's channel until it closes. Returns a function that detaches it. */
@@ -66,19 +74,26 @@ export interface HarnessApiServer {
66
74
  offer(offer: RunOffer): Promise<RunEnd>;
67
75
  /**
68
76
  * Sends a `workspace.*` request to a harness that serves workspaces: with `pod`, the host of
69
- * that pod sandbox; otherwise one that hosts no sandbox. Throws `NoWorkspaceHarness` when
70
- * none is attached.
77
+ * that pod sandbox (with `minEpoch`, one that joined at that host epoch or later, so a pod's
78
+ * new volume is never written through its old host); otherwise one that hosts no sandbox.
79
+ * Throws `NoWorkspaceHarness` when none is attached.
71
80
  */
72
81
  workspace<M extends CoreMethod>(method: M, params: ParamsOf<M>, signal?: AbortSignal, target?: {
73
82
  readonly pod?: string;
83
+ readonly minEpoch?: number;
74
84
  }): Promise<ResultOf<M>>;
75
85
  /**
76
86
  * Pod sandbox `sandboxId`'s host epoch moved to `epoch` (F7.2): connections hosting it at an
77
87
  * older epoch are closed.
78
88
  */
79
89
  revokeHost(sandboxId: string, epoch: number): void;
80
- /** Whether a host of pod sandbox `sandboxId` is connected. */
81
- hosting(sandboxId: string): boolean;
90
+ /**
91
+ * Whether a host of pod sandbox `sandboxId` is connected: with `minEpoch`, one that joined at
92
+ * that host epoch or later. A restore or a lost volume moves the epoch before the old host's
93
+ * connection is closed (`host.revoked` is delivered after the commit), so a caller that writes
94
+ * the new volume passes the Environment's epoch.
95
+ */
96
+ hosting(sandboxId: string, minEpoch?: number): boolean;
82
97
  /** Harnesses attached now, and whether one serves workspaces. */
83
98
  status(): HarnessStatus;
84
99
  /** Harnesses attached now. */
@@ -102,3 +117,11 @@ export interface HarnessApiServerOptions {
102
117
  sandboxPreference?: string;
103
118
  }
104
119
  export declare function createHarnessApiServer(ctx: TenantContext, options?: HarnessApiServerOptions): HarnessApiServer;
120
+ /**
121
+ * The tools a harness runs itself (`features.tools`) whose names the turn already uses: a tool of
122
+ * the turn's agents (their own and the Runtime's `runtime` tools), a session tool, `tool_search` or
123
+ * `tool_call` (session tools a later step may get). Tools are known by name alone, so a native
124
+ * tool must not take one (Harness API 3); `hello` already refuses a name with `__`, which names an
125
+ * MCP server's tools. A flow turn runs no native tools.
126
+ */
127
+ export declare function nativeToolClashes(tools: ReadonlySet<string> | undefined, start: TurnStart): string[];
@@ -27,6 +27,7 @@
27
27
  import { randomUUID } from "node:crypto";
28
28
  import { DEFAULT_HARNESS, HARNESS_API_VERSION, HARNESS_CLAIMS, MAX_HARNESS_STATE_BYTES, isHarnessId, HarnessApiError, } from "@nylorun/core/harness-api";
29
29
  import { definitionFilesOf } from "@nylorun/core/contracts";
30
+ import { TOOL_CALL_TOOL, TOOL_SEARCH_TOOL, delegatesOf, } from "@nylorun/core/define";
30
31
  import { isOwnershipLost } from "../store/ownership.js";
31
32
  import { harnessOf, ownedSession } from "../tenant/context.js";
32
33
  import { mcpDiscovered, pinDeferral, sessionToolsOf, } from "../mcp/snapshot.js";
@@ -80,6 +81,15 @@ export function createHarnessApiServer(ctx, options = {}) {
80
81
  continue;
81
82
  }
82
83
  queued.splice(i, 1);
84
+ const clash = nativeToolClashes(waiting[at].connection.tools, run.offer.start);
85
+ if (clash.length > 0) {
86
+ // The harness keeps waiting for a run its tools fit; this turn fails (Harness API 3).
87
+ run.end({
88
+ kind: "refused",
89
+ message: `Harness '${waiting[at].connection.harness}' runs tools named like tools of this turn's agents: ${clash.join(", ")}. A tool the harness runs itself needs a name no other tool of the session has.`,
90
+ });
91
+ continue;
92
+ }
83
93
  const lease = waiting.splice(at, 1)[0];
84
94
  run.connection = lease.connection;
85
95
  // The run token the advance holds now (F5): the gate calls of this run present it.
@@ -111,14 +121,14 @@ export function createHarnessApiServer(ctx, options = {}) {
111
121
  const serve = (connection) => async (method, params, signal) => {
112
122
  const p = params;
113
123
  if (method === "hello") {
114
- const { api, name, capabilities } = p;
124
+ const { api, name, features } = p;
115
125
  // R3's next version adds a window of supported versions; until then there is one.
116
126
  if (api !== HARNESS_API_VERSION)
117
127
  throw new HarnessApiError("invalid", `unsupported Harness API version ${api}: this Runtime speaks ${HARNESS_API_VERSION}`);
118
128
  connection.harness = await harnessIdOf(connection, name);
119
- connection.workspace = capabilities.workspace !== undefined;
120
- if (capabilities.tools?.length)
121
- connection.tools = new Set(capabilities.tools.map((tool) => tool.name));
129
+ connection.workspace = features.workspace !== undefined;
130
+ if (features.tools?.length)
131
+ connection.tools = new Set(features.tools.map((tool) => tool.name));
122
132
  return {
123
133
  api: HARNESS_API_VERSION,
124
134
  tenantId: ctx.config.tenantId,
@@ -258,12 +268,12 @@ export function createHarnessApiServer(ctx, options = {}) {
258
268
  current.mcpSnapshot = pinDeferral(snapshot, current.manifest, contextWindow);
259
269
  current.mcpDiagnostics = diagnostics;
260
270
  await t.put("sessions", current.id, current);
261
- await t.event(current.id, run.grant.turnId, "mcp.discovered", mcpDiscovered(current.mcpSnapshot, diagnostics), run.connection ? writerOf(run.connection, run.grant.runId) : undefined);
271
+ 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);
262
272
  }
263
273
  else if (diagnostics.length > 0) {
264
274
  const prior = [...(current.mcpDiagnostics ?? [])];
265
275
  for (const item of diagnostics) {
266
- const index = prior.findIndex((existing) => existing.capabilityId === item.capabilityId && existing.serverName === item.serverName);
276
+ const index = prior.findIndex((existing) => (existing.agentId ?? null) === (item.agentId ?? null) && existing.serverName === item.serverName);
267
277
  if (index >= 0)
268
278
  prior[index] = item;
269
279
  else
@@ -336,8 +346,10 @@ export function createHarnessApiServer(ctx, options = {}) {
336
346
  !connection.channel.closed),
337
347
  };
338
348
  },
339
- hosting(sandboxId) {
340
- return [...connections].some((connection) => connection.host?.sandboxId === sandboxId && !connection.channel.closed);
349
+ hosting(sandboxId, minEpoch) {
350
+ return [...connections].some((connection) => connection.host?.sandboxId === sandboxId &&
351
+ connection.host.epoch >= (minEpoch ?? 0) &&
352
+ !connection.channel.closed);
341
353
  },
342
354
  revokeHost(sandboxId, epoch) {
343
355
  for (const connection of [...connections])
@@ -356,6 +368,7 @@ export function createHarnessApiServer(ctx, options = {}) {
356
368
  const connection = [...connections].find((item) => item.workspace &&
357
369
  !item.channel.closed &&
358
370
  item.host?.sandboxId === pod &&
371
+ (item.host === undefined || item.host.epoch >= (target?.minEpoch ?? 0)) &&
359
372
  (pod !== undefined || item.harness === harness));
360
373
  if (!connection)
361
374
  throw new NoWorkspaceHarness(pod);
@@ -496,3 +509,23 @@ function isSnapshot(value) {
496
509
  typeof snapshot.manifestHash === "string" &&
497
510
  Array.isArray(snapshot.mcpTools));
498
511
  }
512
+ /**
513
+ * The tools a harness runs itself (`features.tools`) whose names the turn already uses: a tool of
514
+ * the turn's agents (their own and the Runtime's `runtime` tools), a session tool, `tool_search` or
515
+ * `tool_call` (session tools a later step may get). Tools are known by name alone, so a native
516
+ * tool must not take one (Harness API 3); `hello` already refuses a name with `__`, which names an
517
+ * MCP server's tools. A flow turn runs no native tools.
518
+ */
519
+ export function nativeToolClashes(tools, start) {
520
+ if (!tools?.size || start.engine !== "agent")
521
+ return [];
522
+ const manifest = start.manifest;
523
+ const taken = new Set([TOOL_SEARCH_TOOL, TOOL_CALL_TOOL]);
524
+ for (const agent of [manifest, ...delegatesOf(manifest).map((delegate) => delegate.manifest)])
525
+ for (const tool of [...(agent.tools ?? []), ...(agent.runtime?.tools ?? [])])
526
+ taken.add(tool.name);
527
+ for (const tool of (start.sessionTools ?? []))
528
+ if (typeof tool?.name === "string")
529
+ taken.add(tool.name);
530
+ return [...tools].filter((name) => taken.has(name)).sort();
531
+ }
@@ -18,7 +18,7 @@
18
18
  * (`policy`). A flow's HTTP stage, which names no tool call, ends in `node.completed` instead.
19
19
  */
20
20
  import type { EventWriter } from "@nylorun/core/contracts";
21
- import type { AgentManifest } from "@nylorun/core/define";
21
+ import { type AgentManifest, type PluginLabel } from "@nylorun/core/define";
22
22
  import type { HostEffect } from "@nylorun/harness/run";
23
23
  import type { Session } from "../tenant/context.js";
24
24
  import { type GateTimes, type ToolCallFacts, type ToolCallKind, type ToolCallStatus, type ToolPolicyFact } from "../tenant/transcript.js";
@@ -40,6 +40,9 @@ export interface ToolRowFacts {
40
40
  };
41
41
  toolKind?: ToolCallKind;
42
42
  server?: string;
43
+ /** The skill the call ran, and the plugin that brought its skill or MCP server. */
44
+ skill?: string;
45
+ plugin?: PluginLabel;
43
46
  attempts?: number;
44
47
  startedAt?: string;
45
48
  settledAt?: string;
@@ -57,16 +60,22 @@ export type ToolRow = EffectDoc & ToolRowFacts & {
57
60
  };
58
61
  error?: string;
59
62
  };
60
- /** Where a tool call runs, and its MCP server or HTTP tool capability. */
61
- export declare function toolKindOf(s: Pick<Session, "manifest" | "mcpSnapshot">, agentManifest: AgentManifest | undefined, request: HostEffect): {
63
+ /** Where a tool call runs, and what it traces to (Q7, Q11). */
64
+ export interface ToolCallPlace {
62
65
  toolKind: ToolCallKind;
66
+ /** Its MCP server or, for an agent's HTTP tool, its URL's host. */
63
67
  server?: string;
64
- };
68
+ skill?: string;
69
+ plugin?: PluginLabel;
70
+ }
71
+ /**
72
+ * Where a tool call runs, and what it traces to: an MCP call names its server and the plugin
73
+ * that brought it; a skill tool, or a sandbox tool whose input names `/skills/<name>@<hash>/`, names the
74
+ * skill and its plugin. A tool the turn's harness declared (`nativeTools`) runs in the harness.
75
+ */
76
+ export declare function toolKindOf(s: Pick<Session, "manifest" | "mcpSnapshot">, agentManifest: AgentManifest | undefined, request: HostEffect, nativeTools?: ReadonlySet<string>): ToolCallPlace;
65
77
  /** The facts a new tool row starts with: its first send, now. */
66
- export declare function firstSend(kind: {
67
- toolKind: ToolCallKind;
68
- server?: string;
69
- }, now?: Date): ToolRowFacts;
78
+ export declare function firstSend(kind: ToolCallPlace, now?: Date): ToolRowFacts;
70
79
  /** A failed outcome the Runtime gives a call a crash interrupted: it may have run. */
71
80
  export declare function interrupted(message: string): {
72
81
  kind: string;
@@ -1,21 +1,49 @@
1
+ import { pluginOf, skillOfToolCall } from "@nylorun/core/define";
1
2
  import { isFlowToolEffect } from "../core/flow-host.js";
2
- import { isHttpToolCall } from "../gates/http-tool.js";
3
+ import { declaredHttpTool, isHttpToolCall } from "../gates/http-tool.js";
3
4
  import { mcpToolOf } from "../mcp/snapshot.js";
4
- import { sandboxCapabilityOf } from "../sandbox/capability.js";
5
+ import { sandboxToolSpec } from "../sandbox/runtime-tools.js";
5
6
  import { toolCompleted, } from "../tenant/transcript.js";
6
7
  import { toolCallDuration, toolCallStatus } from "./tool-status.js";
7
8
  /** How many times a tool call is sent before it fails to the model as `tool.interrupted`. */
8
9
  export const MAX_TOOL_ATTEMPTS = 3;
9
- /** Where a tool call runs, and its MCP server or HTTP tool capability. */
10
- export function toolKindOf(s, agentManifest, request) {
10
+ /**
11
+ * Where a tool call runs, and what it traces to: an MCP call names its server and the plugin
12
+ * that brought it; a skill tool, or a sandbox tool whose input names `/skills/<name>@<hash>/`, names the
13
+ * skill and its plugin. A tool the turn's harness declared (`nativeTools`) runs in the harness.
14
+ */
15
+ export function toolKindOf(s, agentManifest, request, nativeTools) {
11
16
  const mcp = mcpToolOf(s.mcpSnapshot, request);
12
- if (mcp)
13
- return { toolKind: "mcp", server: mcp.serverName };
14
- if (isHttpToolCall(s.manifest, request))
15
- return { toolKind: "http", ...(request.capabilityId ? { server: request.capabilityId } : {}) };
16
- if (sandboxCapabilityOf(agentManifest, request.capabilityId, request.toolName))
17
- return { toolKind: "sandbox" };
18
- return { toolKind: "platform" };
17
+ if (mcp) {
18
+ const plugin = pluginOf(agentManifest, { server: mcp.serverName });
19
+ return { toolKind: "mcp", server: mcp.serverName, ...(plugin === undefined ? {} : { plugin }) };
20
+ }
21
+ if (isHttpToolCall(s.manifest, request)) {
22
+ const host = httpHostOf(declaredHttpTool(s.manifest, request.agent?.id, request.toolName)?.http.url);
23
+ return { toolKind: "http", ...(host === undefined ? {} : { server: host }) };
24
+ }
25
+ if (request.toolName !== undefined && nativeTools?.has(request.toolName))
26
+ return { toolKind: "harness" };
27
+ const skill = skillOfToolCall(agentManifest, request.toolName, request.input);
28
+ const plugin = skill === undefined ? undefined : pluginOf(agentManifest, { skill });
29
+ const traced = {
30
+ ...(skill === undefined ? {} : { skill }),
31
+ ...(plugin === undefined ? {} : { plugin }),
32
+ };
33
+ if (sandboxToolSpec(agentManifest, request.toolName))
34
+ return { toolKind: "sandbox", ...traced };
35
+ return { toolKind: "platform", ...traced };
36
+ }
37
+ /** The host (and port, when it names one) of an HTTP tool's URL. */
38
+ function httpHostOf(url) {
39
+ if (url === undefined)
40
+ return undefined;
41
+ try {
42
+ return new URL(url).host || undefined;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
19
47
  }
20
48
  /** The facts a new tool row starts with: its first send, now. */
21
49
  export function firstSend(kind, now = new Date()) {
@@ -33,6 +61,8 @@ export function factsOf(row, status, gate) {
33
61
  status,
34
62
  ...(row.toolKind === undefined ? {} : { kind: row.toolKind }),
35
63
  ...(row.server === undefined ? {} : { server: row.server }),
64
+ ...(row.skill === undefined ? {} : { skill: row.skill }),
65
+ ...(row.plugin === undefined ? {} : { plugin: row.plugin }),
36
66
  ...(row.attempts === undefined ? {} : { attempts: row.attempts }),
37
67
  ...(durationMs === undefined ? {} : { durationMs }),
38
68
  effectId: row.request.effectId,
@@ -1,4 +1,4 @@
1
- import type { CapabilityManifest, SandboxToolName } from "@nylorun/core/define";
1
+ import type { SandboxManifest, SandboxToolName } from "@nylorun/core/define";
2
2
  import type { SandboxManager, SandboxSessionRef } from "../sandbox/manager.js";
3
3
  import { type DiskOp } from "../sandbox/disk-fs.js";
4
4
  import type { SandboxSelectionReport } from "../sandbox/select.js";
@@ -13,13 +13,13 @@ export type WorkspaceReport = SandboxSelectionReport & {
13
13
  type Exists = (id: string) => boolean | Promise<boolean>;
14
14
  export interface WorkspacePort {
15
15
  /** Runs one sandbox tool in the workspace of `session`. */
16
- run(session: SandboxSessionRef, capability: CapabilityManifest, toolName: SandboxToolName, input: unknown, signal: AbortSignal): Promise<SandboxToolOutcome>;
16
+ run(session: SandboxSessionRef, sandbox: SandboxManifest | undefined, toolName: SandboxToolName, input: unknown, signal: AbortSignal): Promise<SandboxToolOutcome>;
17
17
  /** Lists the files under `dir` of the workspace of `session` (the turn-end export, F8.2). */
18
- listFiles(session: SandboxSessionRef, capability: CapabilityManifest, dir: string, maxEntries: number, signal: AbortSignal): ReturnType<SandboxManager["listFiles"]>;
18
+ listFiles(session: SandboxSessionRef, sandbox: SandboxManifest | undefined, dir: string, maxEntries: number, signal: AbortSignal): ReturnType<SandboxManager["listFiles"]>;
19
19
  /** Reads a file of the workspace of `session` as bytes, at most `maxBytes` (`save_artifact`). */
20
- readBytes(session: SandboxSessionRef, capability: CapabilityManifest, path: string, maxBytes: number, signal: AbortSignal): ReturnType<SandboxManager["readBytes"]>;
20
+ readBytes(session: SandboxSessionRef, sandbox: SandboxManifest | undefined, path: string, maxBytes: number, signal: AbortSignal): ReturnType<SandboxManager["readBytes"]>;
21
21
  /** One operation on the disk of the workspace of `session` (R2d step 3). */
22
- disk(session: SandboxSessionRef, capability: CapabilityManifest, op: DiskOp, signal: AbortSignal): ReturnType<SandboxManager["disk"]>;
22
+ disk(session: SandboxSessionRef, sandbox: SandboxManifest | undefined, op: DiskOp, signal: AbortSignal): ReturnType<SandboxManager["disk"]>;
23
23
  report(): Promise<WorkspaceReport>;
24
24
  /** One Tenant sweep pass: stop idle workspaces, remove those whose owner is gone. */
25
25
  sweep(options: {
@@ -43,6 +43,8 @@ export interface RemoteWorkspaceOptions {
43
43
  readonly preference: string;
44
44
  /** The pod sandbox whose host serves these workspaces (F7.2). */
45
45
  readonly pod?: string;
46
+ /** With `pod`: the oldest host epoch that may serve them (`SandboxSessionRef.hostEpoch`). */
47
+ readonly minEpoch?: number;
46
48
  }
47
49
  /** The workspaces of the harness that serves them. */
48
50
  export declare function remoteWorkspace(options: RemoteWorkspaceOptions): WorkspacePort;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The workspace capability (F6.2): what core does with the Tenant's sandbox workspaces outside a
2
+ * The workspace feature (F6.2): what core does with the Tenant's sandbox workspaces outside a
3
3
  * run. `ctx.sandbox` is a `WorkspacePort`: the sandbox tool route (`POST
4
4
  * /v1/sessions/:id/sandbox/:tool`), Tenant status, the Tenant sweep, a sandbox resource's deletion and a sandboxes reset.
5
5
  *
@@ -25,10 +25,10 @@ import { NoWorkspaceHarness } from "./server.js";
25
25
  /** The Tenant's own SandboxManager. */
26
26
  export function localWorkspace(manager) {
27
27
  return {
28
- run: (session, capability, toolName, input, signal) => manager.run(session, capability, toolName, input, signal),
29
- listFiles: (session, capability, dir, maxEntries, signal) => manager.listFiles(session, capability, dir, maxEntries, signal),
30
- readBytes: (session, capability, path, maxBytes, signal) => manager.readBytes(session, capability, path, maxBytes, signal),
31
- disk: (session, capability, op, signal) => manager.disk(session, capability, op, signal),
28
+ run: (session, sandbox, toolName, input, signal) => manager.run(session, sandbox, toolName, input, signal),
29
+ listFiles: (session, sandbox, dir, maxEntries, signal) => manager.listFiles(session, sandbox, dir, maxEntries, signal),
30
+ readBytes: (session, sandbox, path, maxBytes, signal) => manager.readBytes(session, sandbox, path, maxBytes, signal),
31
+ disk: (session, sandbox, op, signal) => manager.disk(session, sandbox, op, signal),
32
32
  report: () => manager.report(),
33
33
  sweep: (options) => manager.sweep(options),
34
34
  removeSandbox: (environmentId) => manager.removeSandbox(environmentId),
@@ -54,7 +54,9 @@ function sessionOf(session) {
54
54
  /** The workspaces of the harness that serves them. */
55
55
  export function remoteWorkspace(options) {
56
56
  const { store } = options;
57
- const target = options.pod === undefined ? undefined : { pod: options.pod };
57
+ const target = options.pod === undefined
58
+ ? undefined
59
+ : { pod: options.pod, ...(options.minEpoch === undefined ? {} : { minEpoch: options.minEpoch }) };
58
60
  const unavailable = (error) => error.pod === undefined
59
61
  ? fail(503, error.message, { code: "request_rejected" })
60
62
  : fail(409, error.message, { code: "environment_unavailable" });
@@ -98,11 +100,11 @@ export function remoteWorkspace(options) {
98
100
  });
99
101
  };
100
102
  return {
101
- async run(session, capability, toolName, input, signal) {
103
+ async run(session, sandbox, toolName, input, signal) {
102
104
  try {
103
105
  return (await options.server.workspace(METHODS[toolName], {
104
106
  session: sessionOf(session),
105
- spec: capability.sandbox ?? {},
107
+ spec: sandbox ?? {},
106
108
  tool: toolName,
107
109
  input: input ?? {},
108
110
  }, signal, target));
@@ -117,9 +119,9 @@ export function remoteWorkspace(options) {
117
119
  throw error;
118
120
  }
119
121
  },
120
- async listFiles(session, capability, dir, maxEntries, signal) {
122
+ async listFiles(session, sandbox, dir, maxEntries, signal) {
121
123
  try {
122
- const answer = (await options.server.workspace("workspace.read", { session: sessionOf(session), spec: capability.sandbox ?? {}, list: { dir, maxEntries } }, signal, target));
124
+ const answer = (await options.server.workspace("workspace.read", { session: sessionOf(session), spec: sandbox ?? {}, list: { dir, maxEntries } }, signal, target));
123
125
  if (answer.kind === "listed")
124
126
  return {
125
127
  kind: "listed",
@@ -137,11 +139,11 @@ export function remoteWorkspace(options) {
137
139
  throw error;
138
140
  }
139
141
  },
140
- async readBytes(session, capability, path, maxBytes, signal) {
142
+ async readBytes(session, sandbox, path, maxBytes, signal) {
141
143
  try {
142
144
  const answer = (await options.server.workspace("workspace.read", {
143
145
  session: sessionOf(session),
144
- spec: capability.sandbox ?? {},
146
+ spec: sandbox ?? {},
145
147
  bytes: { path, maxBytes },
146
148
  }, signal, target));
147
149
  if (answer.kind === "read")
@@ -156,9 +158,9 @@ export function remoteWorkspace(options) {
156
158
  throw error;
157
159
  }
158
160
  },
159
- async disk(session, capability, op, signal) {
161
+ async disk(session, sandbox, op, signal) {
160
162
  try {
161
- const answer = (await options.server.workspace("list" in op || "read" in op ? "workspace.read" : "workspace.write", { session: sessionOf(session), spec: capability.sandbox ?? {}, disk: toWireOp(op) }, signal, target));
163
+ const answer = (await options.server.workspace("list" in op || "read" in op ? "workspace.read" : "workspace.write", { session: sessionOf(session), spec: sandbox ?? {}, disk: toWireOp(op) }, signal, target));
162
164
  return answer.kind === "failed"
163
165
  ? { kind: "failed", code: String(answer.code), message: String(answer.message) }
164
166
  : fromWireAnswer(answer);
@@ -204,13 +206,17 @@ export function remoteWorkspace(options) {
204
206
  */
205
207
  export function withPodWorkspaces(base, options) {
206
208
  const routed = async (session) => session.environmentId !== undefined && (await options.isPod(session.environmentId))
207
- ? remoteWorkspace({ ...options, pod: session.environmentId })
209
+ ? remoteWorkspace({
210
+ ...options,
211
+ pod: session.environmentId,
212
+ ...(session.hostEpoch === undefined ? {} : { minEpoch: session.hostEpoch }),
213
+ })
208
214
  : base;
209
215
  return {
210
216
  ...base,
211
- run: async (session, capability, toolName, input, signal) => (await routed(session)).run(session, capability, toolName, input, signal),
212
- listFiles: async (session, capability, dir, maxEntries, signal) => (await routed(session)).listFiles(session, capability, dir, maxEntries, signal),
213
- readBytes: async (session, capability, path, maxBytes, signal) => (await routed(session)).readBytes(session, capability, path, maxBytes, signal),
214
- disk: async (session, capability, op, signal) => (await routed(session)).disk(session, capability, op, signal),
217
+ run: async (session, sandbox, toolName, input, signal) => (await routed(session)).run(session, sandbox, toolName, input, signal),
218
+ listFiles: async (session, sandbox, dir, maxEntries, signal) => (await routed(session)).listFiles(session, sandbox, dir, maxEntries, signal),
219
+ readBytes: async (session, sandbox, path, maxBytes, signal) => (await routed(session)).readBytes(session, sandbox, path, maxBytes, signal),
220
+ disk: async (session, sandbox, op, signal) => (await routed(session)).disk(session, sandbox, op, signal),
215
221
  };
216
222
  }
@@ -178,8 +178,8 @@ export async function startEgress(options) {
178
178
  sandboxes: { live: async (sandboxId) => storeEgressSandboxes((await tenant.open()).store).live(sandboxId) },
179
179
  // Each connection on the Tenant's record (R7.3): the gates' one write into it.
180
180
  record: {
181
- holder: async (environmentId) => storeEgressRecord((await tenant.open()).store).holder(environmentId),
182
- write: async (holder, connection) => storeEgressRecord((await tenant.open()).store).write(holder, connection),
181
+ runningTurns: async (environmentId) => storeEgressRecord((await tenant.open()).store).runningTurns(environmentId),
182
+ write: async (connection) => storeEgressRecord((await tenant.open()).store).write(connection),
183
183
  },
184
184
  credentials: {
185
185
  bound: async (sandboxId, host) => (await tenant.open()).environmentSecretBound(sandboxId, host),
@@ -459,7 +459,7 @@ function parseSandboxes(env) {
459
459
  return undefined;
460
460
  const token = read(env, "NYLORUN_SANDBOXES_TOKEN");
461
461
  if (token === undefined || token.length < 32)
462
- throw new StackConfigError("NYLORUN_SANDBOXES_TOKEN (at least 32 characters) is required with NYLORUN_SANDBOXES_URL (`nylorun sandbox enable` sets both)");
462
+ throw new StackConfigError("NYLORUN_SANDBOXES_TOKEN (at least 32 characters) is required with NYLORUN_SANDBOXES_URL (`nylorun local sandbox enable` sets both)");
463
463
  const harnessImage = read(env, "NYLORUN_SANDBOX_HARNESS_IMAGE");
464
464
  if (harnessImage === undefined || /\s/.test(harnessImage))
465
465
  throw new StackConfigError("NYLORUN_SANDBOX_HARNESS_IMAGE is required with NYLORUN_SANDBOXES_URL: the Runtime image sandbox pods copy the engine from (`nylorun start` sets it)");