@nylorun/runtime 0.15.1-beta → 0.16.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 (220) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +33 -6
  3. package/dist/adapters/execution/restate.d.ts +9 -1
  4. package/dist/adapters/execution/restate.js +64 -5
  5. package/dist/adapters/replication/pgoutput.js +3 -1
  6. package/dist/adapters/sandbox/local.d.ts +10 -0
  7. package/dist/adapters/sandbox/local.js +195 -0
  8. package/dist/adapters/sandbox/virtual.js +36 -1
  9. package/dist/admin-openapi.json +37 -3
  10. package/dist/api/a2a/tasks.js +6 -1
  11. package/dist/api/ag-ui/history.js +6 -1
  12. package/dist/api/ag-ui/routes.js +5 -1
  13. package/dist/api/components.d.ts +2673 -655
  14. package/dist/api/components.js +17 -1
  15. package/dist/api/gate/keys.d.ts +4 -3
  16. package/dist/api/gate/routes.d.ts +19 -3
  17. package/dist/api/gate/routes.js +162 -53
  18. package/dist/api/http/app.js +4 -0
  19. package/dist/api/http/define.d.ts +10 -0
  20. package/dist/api/http/define.js +2 -1
  21. package/dist/api/http/routes/artifacts.d.ts +11 -0
  22. package/dist/api/http/routes/artifacts.js +518 -0
  23. package/dist/api/http/routes/sandboxes.d.ts +9 -0
  24. package/dist/api/http/routes/sandboxes.js +152 -0
  25. package/dist/api/http/routes/sessions.js +3 -1
  26. package/dist/api/http/routes/tenant.js +36 -2
  27. package/dist/artifacts/config.d.ts +23 -0
  28. package/dist/artifacts/config.js +27 -0
  29. package/dist/artifacts/export.d.ts +8 -0
  30. package/dist/artifacts/export.js +205 -0
  31. package/dist/artifacts/files.d.ts +36 -0
  32. package/dist/artifacts/files.js +45 -0
  33. package/dist/artifacts/folders.d.ts +60 -0
  34. package/dist/artifacts/folders.js +209 -0
  35. package/dist/artifacts/links.d.ts +28 -0
  36. package/dist/artifacts/links.js +92 -0
  37. package/dist/artifacts/media-types.d.ts +13 -0
  38. package/dist/artifacts/media-types.js +80 -0
  39. package/dist/artifacts/parts.d.ts +34 -0
  40. package/dist/artifacts/parts.js +38 -0
  41. package/dist/artifacts/service.d.ts +74 -0
  42. package/dist/artifacts/service.js +301 -0
  43. package/dist/artifacts/workspace.d.ts +60 -0
  44. package/dist/artifacts/workspace.js +57 -0
  45. package/dist/artifacts/zip.d.ts +10 -0
  46. package/dist/artifacts/zip.js +126 -0
  47. package/dist/blob/fs.d.ts +13 -0
  48. package/dist/blob/fs.js +194 -0
  49. package/dist/blob/index.d.ts +9 -0
  50. package/dist/blob/index.js +9 -0
  51. package/dist/blob/s3.d.ts +42 -0
  52. package/dist/blob/s3.js +366 -0
  53. package/dist/blob/types.d.ts +108 -0
  54. package/dist/blob/types.js +85 -0
  55. package/dist/core/sandbox-routes.d.ts +2 -2
  56. package/dist/core/sandbox-routes.js +4 -3
  57. package/dist/execution/memory.d.ts +11 -1
  58. package/dist/execution/memory.js +77 -3
  59. package/dist/execution/types.d.ts +54 -1
  60. package/dist/execution/types.js +31 -0
  61. package/dist/gates/contract.d.ts +14 -13
  62. package/dist/gates/contract.js +13 -12
  63. package/dist/gates/egress.d.ts +72 -0
  64. package/dist/gates/egress.js +319 -0
  65. package/dist/gates/handler.d.ts +3 -0
  66. package/dist/gates/handler.js +3 -2
  67. package/dist/gates/http-client.d.ts +3 -2
  68. package/dist/gates/http-client.js +17 -6
  69. package/dist/gates/in-process.d.ts +13 -1
  70. package/dist/gates/in-process.js +11 -0
  71. package/dist/gates/inflight.d.ts +23 -4
  72. package/dist/gates/inflight.js +18 -4
  73. package/dist/gates/model-gate.d.ts +6 -2
  74. package/dist/gates/tool-calls.d.ts +9 -5
  75. package/dist/gates/tool-calls.js +6 -6
  76. package/dist/gates/tool-client.d.ts +9 -2
  77. package/dist/gates/tool-client.js +56 -22
  78. package/dist/gates/tool-contract.d.ts +12 -7
  79. package/dist/gates/tool-contract.js +10 -4
  80. package/dist/gates/tool-gate.d.ts +1 -0
  81. package/dist/harness/calls.d.ts +52 -0
  82. package/dist/harness/calls.js +113 -0
  83. package/dist/harness/executors.d.ts +22 -0
  84. package/dist/harness/executors.js +96 -0
  85. package/dist/harness/main.d.ts +6 -0
  86. package/dist/harness/main.js +103 -0
  87. package/dist/harness/pod.d.ts +21 -0
  88. package/dist/harness/pod.js +119 -0
  89. package/dist/harness/run-tokens.d.ts +18 -0
  90. package/dist/harness/run-tokens.js +15 -0
  91. package/dist/harness/service.d.ts +46 -0
  92. package/dist/harness/service.js +179 -0
  93. package/dist/harness/ws-client.d.ts +43 -0
  94. package/dist/harness/ws-client.js +122 -0
  95. package/dist/harness/ws-port.d.ts +22 -0
  96. package/dist/harness/ws-port.js +78 -0
  97. package/dist/harness-api/in-process.d.ts +24 -0
  98. package/dist/harness-api/in-process.js +119 -0
  99. package/dist/harness-api/record.d.ts +44 -0
  100. package/dist/harness-api/record.js +250 -0
  101. package/dist/harness-api/renew.d.ts +37 -0
  102. package/dist/harness-api/renew.js +77 -0
  103. package/dist/harness-api/server.d.ts +104 -0
  104. package/dist/harness-api/server.js +408 -0
  105. package/dist/harness-api/start.d.ts +36 -0
  106. package/dist/harness-api/start.js +118 -0
  107. package/dist/harness-api/workspace.d.ts +53 -0
  108. package/dist/harness-api/workspace.js +201 -0
  109. package/dist/harness-api/ws-server.d.ts +34 -0
  110. package/dist/harness-api/ws-server.js +227 -0
  111. package/dist/host/app.js +23 -2
  112. package/dist/host/gates.d.ts +27 -2
  113. package/dist/host/gates.js +46 -1
  114. package/dist/host/http.d.ts +4 -1
  115. package/dist/host/http.js +6 -1
  116. package/dist/host/main.js +135 -27
  117. package/dist/host/stack-config.d.ts +101 -1
  118. package/dist/host/stack-config.js +199 -8
  119. package/dist/mcp/snapshot.d.ts +8 -1
  120. package/dist/mcp/snapshot.js +28 -0
  121. package/dist/media.d.ts +5 -24
  122. package/dist/media.js +5 -0
  123. package/dist/model/pi-model.d.ts +6 -2
  124. package/dist/model/pi-model.js +39 -22
  125. package/dist/node/index.d.ts +1 -1
  126. package/dist/node/index.js +1 -1
  127. package/dist/openapi.json +15245 -8850
  128. package/dist/record/index.d.ts +1 -0
  129. package/dist/record/index.js +1 -0
  130. package/dist/record/sandbox.d.ts +25 -0
  131. package/dist/record/sandbox.js +43 -0
  132. package/dist/sandbox/capability.d.ts +3 -0
  133. package/dist/sandbox/capability.js +10 -0
  134. package/dist/sandbox/egress-token.d.ts +26 -0
  135. package/dist/sandbox/egress-token.js +8 -0
  136. package/dist/sandbox/join.d.ts +37 -0
  137. package/dist/sandbox/join.js +165 -0
  138. package/dist/sandbox/manager.d.ts +99 -34
  139. package/dist/sandbox/manager.js +216 -84
  140. package/dist/sandbox/placement.d.ts +27 -0
  141. package/dist/sandbox/placement.js +44 -0
  142. package/dist/sandbox/pods/client.d.ts +90 -0
  143. package/dist/sandbox/pods/client.js +79 -0
  144. package/dist/sandbox/pods/lifecycle.d.ts +91 -0
  145. package/dist/sandbox/pods/lifecycle.js +178 -0
  146. package/dist/sandbox/pods/name.d.ts +1 -0
  147. package/dist/sandbox/pods/name.js +28 -0
  148. package/dist/sandbox/pods/network-gate.d.ts +25 -0
  149. package/dist/sandbox/pods/network-gate.js +71 -0
  150. package/dist/sandbox/pods/reconcile.d.ts +11 -0
  151. package/dist/sandbox/pods/reconcile.js +152 -0
  152. package/dist/sandbox/pods/spec.d.ts +45 -0
  153. package/dist/sandbox/pods/spec.js +62 -0
  154. package/dist/sandbox/records.d.ts +29 -0
  155. package/dist/sandbox/records.js +79 -0
  156. package/dist/sandbox/resolve.d.ts +5 -0
  157. package/dist/sandbox/resolve.js +3 -2
  158. package/dist/sandbox/session-sandbox.js +7 -5
  159. package/dist/sandbox/share.d.ts +11 -0
  160. package/dist/sandbox/share.js +10 -0
  161. package/dist/sandbox/tenant-config.d.ts +16 -1
  162. package/dist/sandbox/tenant-config.js +9 -0
  163. package/dist/sandbox/types.d.ts +29 -1
  164. package/dist/sandbox/types.js +13 -1
  165. package/dist/store/postgres/drizzle/0004_sandbox_resources.sql +20 -0
  166. package/dist/store/postgres/drizzle/0005_artifacts.sql +27 -0
  167. package/dist/store/postgres/drizzle/0006_folder_artifacts.sql +12 -0
  168. package/dist/store/postgres/drizzle/0007_sandbox_pods.sql +14 -0
  169. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  170. package/dist/store/postgres/record-writer.d.ts +6 -1
  171. package/dist/store/postgres/record-writer.js +20 -2
  172. package/dist/store/postgres/schema.d.ts +894 -0
  173. package/dist/store/postgres/schema.js +130 -2
  174. package/dist/store/postgres/store.js +289 -4
  175. package/dist/store/types.d.ts +143 -6
  176. package/dist/streams/control.d.ts +2 -0
  177. package/dist/streams/control.js +5 -0
  178. package/dist/streams/types.d.ts +10 -1
  179. package/dist/tenant/advance.js +218 -160
  180. package/dist/tenant/artifact-tool.d.ts +5 -0
  181. package/dist/tenant/artifact-tool.js +119 -0
  182. package/dist/tenant/commands.js +30 -5
  183. package/dist/tenant/context.d.ts +64 -5
  184. package/dist/tenant/effects.d.ts +19 -13
  185. package/dist/tenant/effects.js +5 -382
  186. package/dist/tenant/history.d.ts +13 -23
  187. package/dist/tenant/history.js +14 -62
  188. package/dist/tenant/host-token.d.ts +92 -0
  189. package/dist/tenant/host-token.js +179 -0
  190. package/dist/tenant/module.js +1 -0
  191. package/dist/tenant/paths.d.ts +2 -1
  192. package/dist/tenant/paths.js +3 -1
  193. package/dist/tenant/reset.d.ts +5 -2
  194. package/dist/tenant/reset.js +22 -2
  195. package/dist/tenant/run-grants.d.ts +49 -0
  196. package/dist/tenant/run-grants.js +62 -0
  197. package/dist/tenant/run-token.d.ts +70 -0
  198. package/dist/tenant/run-token.js +138 -0
  199. package/dist/tenant/runtime.d.ts +38 -0
  200. package/dist/tenant/runtime.js +120 -32
  201. package/dist/tenant/sandboxes.d.ts +119 -0
  202. package/dist/tenant/sandboxes.js +551 -0
  203. package/dist/tenant/session.d.ts +2 -13
  204. package/dist/tenant/session.js +4 -28
  205. package/dist/tenant/sessions.d.ts +5 -0
  206. package/dist/tenant/sessions.js +28 -1
  207. package/dist/tenant/status.d.ts +8 -3
  208. package/dist/tenant/status.js +5 -0
  209. package/dist/tenant/streams.d.ts +7 -0
  210. package/dist/tenant/streams.js +24 -1
  211. package/dist/tenant/sweep.js +37 -4
  212. package/dist/tenant/tokens.js +9 -2
  213. package/dist/tenant/types.d.ts +18 -1
  214. package/dist/tenant/worker.d.ts +11 -5
  215. package/dist/tenant/worker.js +12 -6
  216. package/dist/version.d.ts +1 -1
  217. package/dist/version.js +1 -1
  218. package/package.json +13 -10
  219. package/dist/adapters/media.d.ts +0 -24
  220. package/dist/adapters/media.js +0 -111
package/CHANGELOG.md CHANGED
@@ -1,5 +1,87 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0-beta
4
+
5
+ ### Major Changes
6
+
7
+ - b352feb: **File artifacts, message parts and capability links (protocol 6).** Upgrade these with the Runtime: they speak protocol 6, which this Runtime serves beside 4 and 5. See MIGRATION.md.
8
+
9
+ - **`@nylorun/runtime`: file artifacts.** A file is an artifact: an `af_` id, a name and numbered immutable versions, with rows in the new `artifacts` and `artifact_versions` tables (migration `0004_artifacts`) and bytes in the Object store (`BlobStore`). `POST /v1/artifacts?name=&sessionId=` uploads a file in one streamed request and `POST /v1/artifacts/{id}/versions` adds a version, within the Tenant's limits (`GET`/`PUT /v1/tenant/artifacts`: 100 MiB per file and 10 GiB in all by default); a body past either is `413 limit_exceeded`, refused mid-stream with nothing stored. `GET /v1/artifacts` lists them (by session), `GET /v1/artifacts/{id}` reads one with its versions, `GET /v1/artifacts/{id}/versions/{n|latest}/content` downloads through the Runtime with HTTP Range (`206`, `Content-Range`, `416`), and `DELETE /v1/artifacts/{id}` deletes it with its bytes. A session's artifacts go with it on a sessions reset, and appear in its history as `artifact.created`, `artifact.version.created` and `artifact.deleted` (new in the event catalog). A subject reaches only the artifacts of their own sessions.
10
+ - **`@nylorun/runtime`: capability links.** `POST /v1/artifacts/{id}/links` mints a short-lived path, `/v1/artifact-links/<token>`, that downloads one version (with Range) with no credential and no `Nylorun-Protocol`: an ES256 JWT (`typ: nylorun-artifact+jwt`) signed with the Tenant's signing key, at most 15 minutes, and dead once the artifact is deleted. The Host logs its path without the token.
11
+ - **Breaking (`@nylorun/core`, `@nylorun/runtime`): message `parts`.** A user message may carry `parts`: `text`, and `file` by `artifactId` (and `version`, else the latest, pinned when the message is accepted). Model-gate reads the file from the Object store: an image goes to the model as image input, a text file as text, and any other file fails the call (`invalid_request`). The record and the transcript hold only the reference. The gateway builds its `BlobStore` from `NYLORUN_OBJECT_STORE_*` (the Tenant's `fs` store without it). `PROTOCOL_VERSION` is 6; `HOST_PROTOCOL` is 4–6 and protocol 6 clients require the feature `artifacts`.
12
+ - **`@nylorun/runtime`: `save_artifact`.** A session with a sandbox gets the `nylorun.artifacts` capability, whose `save_artifact` tool saves a sandbox file (`path`) or text (`content`) as an artifact of the session, carrying the turn and tool call on its event.
13
+ - **Breaking (`@nylorun/runtime`): `MediaStore` is removed.** `MediaStore` and `localMedia` leave `@nylorun/runtime/node`, and `piModel` takes `files` (a resolver from an artifact reference to its bytes) instead of `media`; images are file artifacts. The image checks (`decodeImageBase64`, `validateImageBytes`, `IMAGE_MEDIA_TYPES`, `MAX_IMAGE_BYTES`) stay.
14
+ - **`@nylorun/agents`:** `client.artifacts` (`upload`, `uploadVersion`, `list`, `get`, `download` with a range, `link`, `delete`), also on `BrowserClient`, and `session.inputParts(parts)`. A request with a body of its own type keeps it.
15
+ - `@nylorun/admin`, `@nylorun/cli`, `nylorun`, `@nylorun/studio`: speak protocol 6.
16
+
17
+ ### Minor Changes
18
+
19
+ - e58e6ec: **egress-gate: pod sandboxes reach only the hosts their spec allows (F7.2).** A new `egress` service runs in the gateway's process (`--service gates,keys,egress`) on `NYLORUN_EGRESS_LISTEN_PORT` (default 4200). It is a CONNECT proxy: each tunnel needs an egress token (`Proxy-Authorization`, Basic with the token as password or Bearer) for a live sandbox at its current host epoch, a host name (no IP literal) that the sandbox's `network.allow` names exactly or by `*.suffix`, port 443 or 80, and a name that resolves to a public address; the gate connects to that checked address and pipes bytes, with no TLS interception and no credential injection. Plain HTTP is answered `405`; a sandbox holds at most 64 tunnels; idle tunnels close after 5 minutes; refusals are logged, never emitted as events. Egress tokens (`typ: nylorun-egress+jwt`), minted with each host token at a pod's join, are accepted nowhere else; egress-gate also refuses a token of a sandbox that is deleted, lost or expired, or of a pod other than the one that joined. Sandbox spec resolution lets `pod` sandboxes allow `*.suffix` hosts within the Tenant's ceiling (virtual ones keep exact names). With sandboxes enabled, `nylorun` runs the gateway with `egress` and publishes it on `NYLORUN_SANDBOX_BIND` at `NYLORUN_SANDBOX_EGRESS_PORT`, next to the gates.
20
+ - 186ec3f: **A local Tenant has an Object store: RustFS, behind the Runtime's new `BlobStore` seam.** `nylorun start` adds a `rustfs` container (RustFS 1.0.1, single node and single drive, pinned by digest) on the `nylorun-<tenant>-rustfs` volume, unpublished and without its console. Its secret key is generated once into `docker/.env` (`NYLORUN_OBJECT_STORE_SECRET_KEY`), and only the `runtime` and `gateway` containers receive the credential (`NYLORUN_OBJECT_STORE_ENDPOINT`, `_ACCESS_KEY`, `_SECRET_KEY`); the runtime creates the bucket at boot. The Runtime reaches the store through `BlobStore` (put with a streamed body and a size cap, get with a byte range, head, delete, list by prefix) with an `s3` adapter over the plain S3 API and an `fs` adapter, which a Runtime without `NYLORUN_OBJECT_STORE_ENDPOINT` (embedded, ephemeral, tests) uses under the Tenant directory's `blobs/`. Nothing stores files there yet; file artifacts build on it.
21
+ - 6077272: **Folder artifacts and the turn-end outputs export (F8.2).** Part of protocol 6, with file artifacts.
22
+
23
+ - **`@nylorun/runtime`: the turn-end export.** When an agent's turn completes, the Runtime reads `/workspace/outputs` of the session's sandbox and keeps it as a version of the session's folder artifact `outputs`: the first export creates it, and each later turn whose outputs changed adds a version (`artifact.created` / `artifact.version.created` with `kind: "folder"`, `source: "export"`, `fileCount` and `claimed: true`, since the listing and bytes are what the sandbox supplied). Nothing is exported without a sandbox or without outputs. An export past 10,000 files, 1 GiB, the per-file limit or the Tenant total stores nothing and records `artifact.export.skipped` with its reason; a failure records `artifact.export.failed`. Neither fails the turn. Core reads the workspace through one seam, `WorkspaceReader`, which the Harness API and pod sandboxes will implement later.
24
+ - **`@nylorun/runtime`: folder artifacts.** A folder version is a manifest of paths to content-addressed files: each file's bytes are stored once at `blobs/sha256/<hex>`, so an unchanged file is never stored again, and the Tenant total (`GET /v1/tenant/artifacts` `usedBytes`) counts it once. New routes: `GET /v1/artifacts/{id}/versions/{n|latest}/tree` (the manifest), `…/files/{path}` (one file by its percent-encoded path, with Range), `…/diff?from=n` (files added, removed and changed) and `…/zip` (a streamed zip). `POST /v1/artifacts/{id}/links` takes `file` to link one file of a folder; a folder's link without it opens the zip. A folder's `/content`, a new version uploaded to a folder, and a message part naming a folder are `400`. Deleting a folder removes the files no other version names. Migration `0006_folder_artifacts` allows the `folder` kind and adds the `artifact_content` table.
25
+ - **`@nylorun/core`:** `ArtifactKindSchema` (`file`, `folder`), the `export` source, `FolderEntrySchema`, `FolderManifestSchema`, `ArtifactTreeSchema`, `ArtifactDiffSchema`, the `artifact.export.skipped` and `artifact.export.failed` events, and `fileCount` and `claimed` on `artifact.created` / `artifact.version.created`.
26
+ - **`@nylorun/agents`:** `client.artifacts.tree()`, `file()`, `diff()` and `zip()`, and `link(id, { file })`.
27
+
28
+ - a5f9cf5: **Gate trust (F5): run tokens and two gate credentials.** A model or MCP call through the gates service reaches only its own run, and the gate takes the call's scope from a token instead of believing the request body.
29
+
30
+ - **Run tokens.** Each advance that takes a session's lease mints a run token (`tenant/run-token.ts`): an ES256 JWT of `typ` `nylorun-run+jwt` and `aud` `nylorun-gates`, signed by the keys service with the Tenant's current signing key, naming the session (`sub`), its active turn (`trn`), its root agent (`agt`) and the lease epoch (`epc`), for 15 minutes. The lease heartbeat re-mints it when less than 5 minutes remain, and it is dropped when the advance ends or loses the lease. The tokens are internal: not in `openapi.json`, and the JWKS is unchanged.
31
+ - **Two credentials at the gateway.** Model calls and their cancels accept only a run token. Tool calls, their cancels and the MCP connect, list and close routes accept a run token, or `NYLORUN_GATES_TOKEN` (now core's credential) for requests made outside a run. Keys, `/deliveries` and endpoint pings accept only core's credential.
32
+ - **Scope from the token.** The model-call body no longer carries `sessionId`, `turnId` or `agentId` (a body naming them is refused with `400`), and MCP requests under a run token leave out `server.sessionId`: ledger rows, turn and agent caps, and MCP manifest and vault-grant lookup use the token's claims.
33
+ - **Stale tokens die.** Every call under a run token checks that the session's epoch and active turn are still the token's and that it is not cancelled, and answers `409 run_stale` otherwise. A keyed call joins a call already running at the gate only for the same session at the same or a newer epoch, so the new owner's re-send after a takeover keeps working (P1.2, F4.1) and the old owner's is refused. A cancel stops only its own session's call (`403 gate_forbidden` otherwise).
34
+ - In process nothing changes: embedding, `startEphemeralRuntime` and Runtimes without the gates service mint no token. No protocol bump: the public API, protocol 5, the Action endpoint wire and `docker/.env` are unchanged. See `DEPLOYMENT.md`.
35
+
36
+ - b6bf1f5: **Harness API v1, in process (F6.1).** Every segment now runs in a harness: the advance takes the session's lease and offers the segment as a run, and the Tenant's own harness, in the same process, runs the engine and reports how it ended. Core settles it exactly as before. Nothing changes on the wire: protocol 5, durable checkpoint 1, engine `hosted-3` and the Action endpoint wire are the same.
37
+
38
+ - `@nylorun/core/harness-api`: the protocol (messages, Zod schemas, the effect request hash, transcript edits, an RPC channel with an in-process memory transport).
39
+ - `@nylorun/harness/api`: `createHarness({ channel, executors })`, a harness that leases runs, renews their leases, replays a run's recorded outcomes without asking, keeps transcripts by record cursor, and runs model, MCP and sandbox calls through the executors it is given.
40
+ - `@nylorun/runtime`: the Harness API server per Tenant (`TenantHandle.attachHarness`), the in-process harness, and the journal as the Record seam. A model call's journal row now stores the request's hash without its prompt, so a replay never sends a prompt twice. `NYLORUN_HARNESS_API=0` runs the engine in the advance as before, until F6.2 removes it. A Runtime older than this one may fail a turn that was in flight across a downgrade with drift.
41
+
42
+ - 8ed4ea6: **Harness service over WebSocket, with the workspace capability (F6.2).** A Runtime started with `NYLORUN_HARNESS=remote` runs no harness of its own: it opens the Harness API listener (`NYLORUN_HARNESS_LISTEN_HOST`/`_PORT`, default port 4200, `NYLORUN_HARNESS_ALLOWED_HOSTS`), which accepts only the harness credential (`NYLORUN_HARNESS_TOKEN`) on `/nylorun/harness/v1`. The runtime image's `--service harness` connects to it (`NYLORUN_HARNESS_URL`, `NYLORUN_HARNESS_TOKEN`, `NYLORUN_GATES_URL`, `NYLORUN_HARNESS_ROOT`) and runs the Tenant's segments, MCP servers and sandboxes with no store; it refuses to start with a database, the gates' or keys' credential, or Restate settings, and presents only run tokens at the gates. The in-process harness stays the default.
43
+
44
+ - `@nylorun/core/harness-api`: the `workspace.*` requests core sends to a harness that serves workspaces, `tenantId` in the `hello` answer, a workspace record on `sandbox.state` claims, and `TurnStart.options.holdMs`. Tenant and admin status report the Tenant's harnesses (`harness`).
45
+ - `@nylorun/harness/api`: `createHarness` declares capabilities, reports grants (`onGrant`) and the `hello` answer, readies MCP through `executors.prepare` (`session.mcp`), and holds a run while its Action is pending until core sends the outcome (`effect.resolved`).
46
+ - `@nylorun/runtime`: the WebSocket listener and client, `--service harness`, the workspace capability (`ctx.sandbox` is a `WorkspacePort`; sandbox tool routes, `save_artifact`, sweep and reset reach the harness's workspaces), the SandboxManager's records port, and held runs (`actionHoldMs`, default 5 minutes). `save_artifact` runs in core. `NYLORUN_HARNESS_API` and the engine run in the advance are removed; tests run with `NYLORUN_TEST_HARNESS=memory|json|ws`.
47
+
48
+ - 678e085: **Pod sandboxes (F7.2, second part).** With `nylorun sandbox enable`, a sandbox resource of kind `pod` (`PUT /v1/sandboxes/{id}` with `kind: "pod"`, an `image`, `*.suffix` hosts, `storage` and `lifecycle.ttl`) is an agent-sandbox pod on the Tenant's cluster, created at once. The turns of the sessions attached to it run in the pod: the engine is copied from the Runtime image into the pod (the Runtime image now carries tini for it), waits until the pod's NetworkPolicy is in force, exchanges its join token for a host token at the Harness API listener (`POST /nylorun/harness/v1/host/join`, published for pods on the Docker host's address, with the gates), and serves its sandbox alone. New: `POST /v1/sandboxes/{id}/stop` and `/reset`; the Tenant's `limits.ttl`, `lifecycle.onExpiry`, `lifecycle.stopGrace` and `placement`; idle stop; lifecycle events `sandbox.running`, `.suspended`, `.expired`, `.relaunched`, `.lost`, `.reset` and `.failed`; error codes `placement_refused`, `sandbox_lost` and `sandbox_expired`; `cluster` in `GET /v1/tenant/sandbox`; Host feature `sandbox-pods` (additive, protocol unchanged). Without a cluster, kind `pod` is `409 sandbox_unavailable` (it was 400). Migration `0007_sandbox_pods` adds the pod lifecycle columns to `sandbox_resources`.
49
+ - 926711b: **Sandboxes are a resource (F7.1, blueprint D39; Host feature `sandboxes`).** A sandbox has its own id, a kind, a spec and labels, and outlives the sessions attached to it. Additive: protocol 5 is unchanged.
50
+
51
+ - `PUT /v1/sandboxes/{id}` creates a sandbox or finds the one with that id (get-or-create in one call); `GET /v1/sandboxes/{id}`, `GET /v1/sandboxes?label=key=value` (repeatable), `GET /v1/sandboxes/{id}/events` and `DELETE /v1/sandboxes/{id}`. Ids are `/`-separated segments (`team-a/proj-42`), sent percent-encoded as one path segment. Only kind `virtual` runs; `pod` is refused with `sandbox_unavailable`. The spec is resolved against the Tenant's limits and fixed once the sandbox exists; labels can change.
52
+ - A session attaches with `sandbox: { id }` and shares the sandbox's `/workspace` with every other session attached to it. Turns are serial per sandbox: a second session's turn is refused with `409 sandbox_busy` while another runs. Deleting a session (a sessions reset) only detaches it. Deleting a sandbox is refused while a turn runs in it; afterwards an attached session's next turn is refused with `sandbox_unavailable` until a sandbox with that id exists again.
53
+ - Subject tokens carry an `sbx` claim: `POST /v1/tokens` takes `sandboxes`, exact ids or prefixes ending in `/*` (at most 16). A token reaches only the sandboxes they match, checked when a session attaches and at every turn start (`403 sandbox_not_granted`); any other sandbox is the 404 of a missing one. The new scope `sandboxes:write` lets a role create and delete the sandboxes its grants reach. Application keys reach every sandbox.
54
+ - The Tenant holds at most `limits.sandboxes` sandboxes (`PUT /v1/tenant/sandbox`, default 100); one more is `409 limit_exceeded`.
55
+ - Lifecycle events (`sandbox.created`, `sandbox.attached`, `sandbox.detached`, `sandbox.deleted`) go to the sandbox's own stream in the record, through the record module; the session's log records `sandbox.attached`. The sandbox stream is not relayed to S2.
56
+ - New error codes `sandbox_not_granted`, `sandbox_busy` and `sandbox_unavailable`; the session view gains `sandboxId` and `sandboxSource: "sandbox"`. Migration `0004_sandbox_resources` adds `sandbox_resources`, `nylorun_streams.sandbox_events` and the sessions' `sandbox_id` column.
57
+ - `@nylorun/agents`: `client.sandboxes` with `ensure(id, spec)`, `get`, `list({ labels })`, `delete`, `events`, and `forSession({ session, spec })`, which creates a sandbox for one session, opens the session on it, and deletes it with `release()`. It replaces sharing through another session (`sandbox: { session }` and the view's `sandboxOwnerId`, now deprecated). `client.tokens.create` takes `sandboxes`.
58
+ - `nylorun sandbox ls [--label key=value]... [--json]` and `nylorun sandbox rm <id>` list and delete the running local Tenant's sandboxes.
59
+
60
+ - db045cd: **The sandboxes service and `nylorun sandbox enable` (F7.2, first part).** A new image, `ghcr.io/nylorun/sandboxes`, versioned with the Runtime, drives agent-sandbox v1.0.5 Sandboxes in one namespace per Tenant: `PUT`, `GET ?wait=` and `DELETE /v1/pods/{name}` with an operation id, `/ready` and `/v1/info`, behind a bearer token only the runtime container holds. `nylorun sandbox enable --context <name>` installs into that kubeconfig context only (the pinned controller when absent, the namespace `nylorun-sbx-<tenant>`, a ServiceAccount whose Role covers Sandbox lifecycle and join Secrets, no NetworkPolicy or exec rights), refuses a cluster whose NetworkPolicy it cannot prove enforced, records `<Host root>/sandboxes/cluster.json` and the token, and adds the `sandboxes` service to the Tenant; `nylorun sandbox disable` and `nylorun sandbox status` remove and report it. Sessions do not run in pods yet: that comes with the Harness API and egress-gate.
61
+
62
+ ### Patch Changes
63
+
64
+ - 3aaf640: **Closing a Tenant no longer hangs on work that ignores its abort.** `MemoryExecution.stop()` and `RestateExecution.stop()` now abort running handlers and wait for them only up to a stop grace (`stopGraceMs`, default `DEFAULT_STOP_GRACE_MS`, 30 s), then abandon the ones still running. Before, `stop()` waited without a limit, so `TenantRuntime.close()` hung in its first step (`detach`) before its bounded `idle` step could run. Shutdown semantics are unchanged: work is aborted as a `shutdown`, and an abandoned advance's lease lapses so the next advance takes the session over. A Tenant that owns its execution uses its advance grace period as the stop grace; the Restate Worker logs a warning when it abandons handlers and drops their connections, so Restate retries them.
65
+ - 167d01a: **A run core stops while it asks about a pending Action is no longer held.** When core cancelled a run, or stopped it for a shutdown, while the harness waited for core's answer to an Action's `effect.intent`, the harness then held the run for the whole `holdMs` (5 minutes by default): it listened for an abort that had already happened. The run kept its lease, and closing the Tenant waited for it. The harness now gives such a run back at once.
66
+ - Pin core to the tested release.
67
+ - Pin harness to the tested release.
68
+ - Updated dependencies [b352feb]
69
+ - Updated dependencies [6077272]
70
+ - Updated dependencies [b6bf1f5]
71
+ - Updated dependencies [8ed4ea6]
72
+ - Updated dependencies [167d01a]
73
+ - Updated dependencies [678e085]
74
+ - Updated dependencies [926711b]
75
+ - Updated dependencies
76
+ - @nylorun/core@0.12.0-beta
77
+ - @nylorun/harness@0.22.0-beta
78
+
79
+ ## 0.15.2-beta
80
+
81
+ ### Patch Changes
82
+
83
+ - 0cbd5c9: The stream relay's lag is never negative: an idle relay confirms one byte past `pg_current_wal_lsn()`, which reported a lag of -1 and failed `/v1/admin/status` validation (400), so Studio showed an idle, healthy Tenant as unavailable.
84
+
3
85
  ## 0.15.1-beta
4
86
 
5
87
  ### Patch Changes
package/README.md CHANGED
@@ -50,6 +50,18 @@ them. A local Tenant packs `core,loop` into the `runtime` container and
50
50
  api|worker|all` is the deprecated name of `--service core`, `loop` and
51
51
  `core,loop`.
52
52
 
53
+ `harness` runs alone: agent turns, stdio MCP servers and workspaces, apart from
54
+ core. With `NYLORUN_HARNESS=remote`, core runs no turn itself and opens the
55
+ Harness API listener (`NYLORUN_HARNESS_LISTEN_HOST`, `NYLORUN_HARNESS_LISTEN_PORT`,
56
+ default 4200, `NYLORUN_HARNESS_ALLOWED_HOSTS`), which accepts only
57
+ `NYLORUN_HARNESS_TOKEN`; `in-process` (the default outside Compose) runs turns in
58
+ core's process. A `harness` process needs `NYLORUN_HARNESS_URL`,
59
+ `NYLORUN_HARNESS_TOKEN`, `NYLORUN_GATES_URL` and `NYLORUN_HARNESS_ROOT` (default
60
+ `/harness`), answers `/health` on `127.0.0.1:4300`, and refuses to start with a
61
+ database, Restate, keys or gates credential in its environment. A local Tenant
62
+ runs it in the `harness` container, on a network with only the runtime and the
63
+ gateway ([DEPLOYMENT.md](../DEPLOYMENT.md#the-harness-agent-turns-mcp-servers-and-workspaces)).
64
+
53
65
  The container is configured by its environment, which a local Tenant's Compose file
54
66
  sets: `NYLORUN_DATABASE_URL` (required), `NYLORUN_RESTATE_INGRESS_URL`,
55
67
  `NYLORUN_RESTATE_ADMIN_URL`, `NYLORUN_WORKER_URL` and
@@ -59,12 +71,22 @@ sets: `NYLORUN_DATABASE_URL` (required), `NYLORUN_RESTATE_INGRESS_URL`,
59
71
  `NYLORUN_BROWSER_ACCESS` (`on` or `off`) and the operator listener
60
72
  (`NYLORUN_ADMIN_LISTEN_PORT`, `NYLORUN_ADMIN_LISTEN_HOST`,
61
73
  `NYLORUN_ADMIN_ALLOWED_HOSTS`). A process that runs `loop` sends its model
62
- calls to the gate at `NYLORUN_GATES_URL` with `NYLORUN_GATES_TOKEN`; in a
63
- container it refuses to start without them. A `gates` process needs only
74
+ calls to the gate at `NYLORUN_GATES_URL`; in a container it refuses to start
75
+ without it and `NYLORUN_GATES_TOKEN`. That token is core's credential: the gate
76
+ accepts only it for vault writes, token signing and Action deliveries. Model and
77
+ remote MCP calls carry a run token instead, which the loop mints for each
78
+ session it advances, and the gate takes the call's session, turn and agent from
79
+ it; a token whose turn was cancelled or whose session another process took over
80
+ is refused with `409 run_stale`. A `gates` process needs only
64
81
  `NYLORUN_DATABASE_URL`, `NYLORUN_GATES_TOKEN`, its listener
65
82
  (`NYLORUN_GATES_LISTEN_HOST`, `NYLORUN_GATES_LISTEN_PORT`, default 4100, and
66
83
  `NYLORUN_GATES_ALLOWED_HOSTS`) and the Host's `tenant/` directory, which it
67
- never writes; it serves the database's one Tenant. `NYLORUN_PACKING` (`combined` or `split`) is logged at startup.
84
+ never writes; it serves the database's one Tenant. With `egress`
85
+ (`--service gates,keys,egress`, for pod sandboxes) the same process runs
86
+ egress-gate on `NYLORUN_EGRESS_LISTEN_HOST`:`NYLORUN_EGRESS_LISTEN_PORT`
87
+ (default `0.0.0.0:4200`): a CONNECT proxy that admits a pod's egress token and
88
+ tunnels only to the hosts its sandbox spec allows, on 443 or 80, never to a
89
+ private address. `NYLORUN_PACKING` (`combined` or `split`) is logged at startup.
68
90
 
69
91
  With an operator listener (a local Tenant's default: container port 4001), the Host
70
92
  serves two ports. The public one serves the Tenant API, to browsers too when
@@ -97,17 +119,22 @@ The Host root is `NYLORUN_HOME` or `~/.nylorun` (for a local Tenant,
97
119
  | `POST /v1/admin/host/shutdown` | admin key | Host-private; not in `@nylorun/admin` |
98
120
  | `GET /v1/admin/openapi.json` | admin key | The Admin API's OpenAPI 3.2 document |
99
121
  | `/v1/*` Tenant routes | application key, subject token or delivery token | Require `Nylorun-Protocol`; nothing names the Tenant |
122
+ | `GET /v1/artifact-links/{token}` | the link itself | A capability link to one artifact version (protocol 6): no credential, no `Nylorun-Protocol`, Range supported; a folder's link opens its zip, or one of its files |
100
123
 
101
124
  Every route checks `Host` first (`421 host_rejected`) and rejects non-JSON bodies
102
- with `415 unsupported_media_type`. An `Origin` is `403 origin_rejected` on
125
+ with `415 unsupported_media_type`, except an artifact upload (`POST /v1/artifacts`,
126
+ `POST /v1/artifacts/{id}/versions`), whose body is the file in any media type. An `Origin` is `403 origin_rejected` on
103
127
  `/health`, `/ready`, admin routes, and everywhere when browser access is off.
104
128
  With browser access on (feature `browser-access`: a local Tenant's default, or
105
129
  `browserAccess` in `host.json`), the Host answers preflights for browser routes
106
130
  from the route alone, and the Tenant admits an `Origin` only with a publishable
107
131
  key (`Nylorun-Key`) that lists it, adding CORS headers only then; Tenant keys
108
132
  and delivery tokens are refused from browsers before they are looked up. Missing or
109
- unsupported protocol → `426` before authentication (the Host serves protocols 4 and 5).
110
- Protocol 5 clients name no Tenant. A protocol 4 client's `Nylorun-Tenant` naming
133
+ unsupported protocol → `426` before authentication (the Host serves protocols 4, 5 and 6;
134
+ protocol 6 adds file and folder artifacts and message `parts`; at each turn's end the
135
+ Runtime exports `/workspace/outputs` of the session's sandbox as a version of its
136
+ `outputs` folder, read at `/v1/artifacts/{id}/versions/{n|latest}/tree`, `/files/{path}`,
137
+ `/diff?from=` and `/zip`). Protocol 5 and 6 clients name no Tenant. A protocol 4 client's `Nylorun-Tenant` naming
111
138
  another Tenant (or malformed), a publishable key of another Tenant, a Tenant that
112
139
  could not be opened and rejected credentials → opaque `404` with identical body. A path or method no route serves is `404 Route not found`
113
140
  once the caller is known.
@@ -1,4 +1,4 @@
1
- import { type DurableExecution, type StuckInvocation, type Wake, type WorkerHandlers } from "../../execution/types.js";
1
+ import { type DurableExecution, type SandboxSignal, type StuckInvocation, type Wake, type WorkerHandlers } from "../../execution/types.js";
2
2
  export interface RestateTimeouts {
3
3
  /**
4
4
  * How long an invocation may run without journal progress before Restate
@@ -66,6 +66,12 @@ export interface RestateExecutionOptions {
66
66
  forceRegistration?: boolean;
67
67
  /** How long `start` keeps retrying registration while Restate comes up. Default 60000. */
68
68
  registrationTimeoutMs?: number;
69
+ /**
70
+ * How long `stop` waits for running handlers after aborting them; the ones still running
71
+ * then are abandoned and their attempts end with the dropped connections, so Restate
72
+ * retries them. Default `DEFAULT_STOP_GRACE_MS`.
73
+ */
74
+ stopGraceMs?: number;
69
75
  /** Replaces the SDK's console logging. */
70
76
  logger?: (level: string, message: string) => void;
71
77
  }
@@ -91,9 +97,11 @@ export declare class RestateExecution implements DurableExecution {
91
97
  tenant: string;
92
98
  timer: string;
93
99
  action: string;
100
+ sandbox: string;
94
101
  };
95
102
  wake(tenantId: string, sessionId: string, wake: Wake): Promise<void>;
96
103
  deliver(tenantId: string, actionId: string): Promise<void>;
104
+ sandbox(tenantId: string, sandboxId: string, signal: SandboxSignal): Promise<void>;
97
105
  timer(tenantId: string, key: string, at: Date): Promise<void>;
98
106
  armSweep(tenantId: string): Promise<void>;
99
107
  disarmSweep(tenantId: string): Promise<void>;
@@ -22,13 +22,19 @@
22
22
  * no state and makes no `ctx.run`: its only journal entry is the delayed
23
23
  * self-send after a `retry` result, so an endpoint that is down never spends
24
24
  * the retry budget, which is for infrastructure errors.
25
+ * - `<prefix>NylorunSandbox`, key `<tenantId>:<sandboxId>` (F7.2). Its
26
+ * exclusive handlers serialize a pod sandbox's reconciles and timers:
27
+ * `reconcile` calls `WorkerHandlers.sandbox`; `arm` records a timer's latest
28
+ * time (`idle`, `ttl`, or the reconcile's own `retry`) and sends a delayed
29
+ * `fire`; a `fire` for an older time is a no-op, as the timer object's.
30
+ * What a run answers arms its timers and its retry.
25
31
  *
26
32
  * Wakes, deliveries, timers and sweep arming go through the ingress as one-way
27
33
  * sends, so an API node can call them without ever calling `start`.
28
34
  */
29
35
  import { createServer } from "node:http2";
30
36
  import * as restate from "@restatedev/restate-sdk";
31
- import { WAKE_REASONS, parseSessionKey, sessionKey, } from "../../execution/types.js";
37
+ import { DEFAULT_STOP_GRACE_MS, WAKE_REASONS, parseSessionKey, sessionKey, settleWithin, } from "../../execution/types.js";
32
38
  const HOUR_MS = 60 * 60 * 1000;
33
39
  export function createRestateExecution(options) {
34
40
  return new RestateExecution(options);
@@ -55,6 +61,7 @@ export class RestateExecution {
55
61
  tenant: `${prefix}NylorunTenant`,
56
62
  timer: `${prefix}NylorunTimer`,
57
63
  action: `${prefix}NylorunAction`,
64
+ sandbox: `${prefix}NylorunSandbox`,
58
65
  };
59
66
  this.ingressUrl = options.ingressUrl.replace(/\/+$/, "");
60
67
  this.adminUrl = options.adminUrl.replace(/\/+$/, "");
@@ -72,6 +79,16 @@ export class RestateExecution {
72
79
  async deliver(tenantId, actionId) {
73
80
  await this.send(this.names.action, sessionKey(tenantId, actionId), "deliver", {});
74
81
  }
82
+ async sandbox(tenantId, sandboxId, signal) {
83
+ const key = sessionKey(tenantId, sandboxId);
84
+ if (signal.kind === "reconcile")
85
+ await this.send(this.names.sandbox, key, "reconcile", {});
86
+ else
87
+ await this.send(this.names.sandbox, key, "arm", {
88
+ timer: signal.timer,
89
+ at: signal.at,
90
+ });
91
+ }
75
92
  async timer(tenantId, key, at) {
76
93
  await this.send(this.names.timer, sessionKey(tenantId, key), "set", {
77
94
  at: at.getTime(),
@@ -143,8 +160,9 @@ export class RestateExecution {
143
160
  this.stopping = true;
144
161
  for (const controller of this.controllers)
145
162
  controller.abort(new Error("Worker stopping"));
146
- while (this.inflight.size > 0)
147
- await Promise.allSettled(this.inflight);
163
+ const abandoned = await settleWithin(this.inflight, this.options.stopGraceMs ?? DEFAULT_STOP_GRACE_MS);
164
+ if (abandoned > 0)
165
+ this.options.logger?.("warn", `Worker stopped with ${abandoned} handler(s) still running; abandoned`);
148
166
  this.handlers = undefined;
149
167
  const server = this.server;
150
168
  this.server = undefined;
@@ -259,7 +277,48 @@ export class RestateExecution {
259
277
  },
260
278
  options: serviceOptions,
261
279
  });
262
- return [session, tenant, timer, action];
280
+ /** Sets a sandbox timer: the latest time wins; `fire` checks it. */
281
+ const armSandbox = async (ctx, input) => {
282
+ ctx.set(`at:${input.timer}`, input.at);
283
+ const now = await ctx.date.now();
284
+ ctx
285
+ .objectSendClient({ name: names.sandbox }, ctx.key)
286
+ .fire(input, restate.rpc.sendOpts({ delay: Math.max(0, input.at - now) }));
287
+ };
288
+ const runSandbox = async (ctx, trigger) => {
289
+ const { tenantId, sessionId: sandboxId } = parseSessionKey(ctx.key);
290
+ const result = await this.runAborted(ctx.request().attemptCompletedSignal, (handlers, signal) => {
291
+ if (!handlers.sandbox)
292
+ throw new Error("WorkerHandlers.sandbox is required for sandbox reconciles");
293
+ return handlers.sandbox(tenantId, sandboxId, trigger, signal);
294
+ });
295
+ for (const item of result.arm ?? [])
296
+ await armSandbox(ctx, item);
297
+ if (result.retryAfterMs !== undefined)
298
+ await armSandbox(ctx, {
299
+ timer: "retry",
300
+ at: (await ctx.date.now()) + Math.max(0, result.retryAfterMs),
301
+ });
302
+ };
303
+ const sandbox = restate.object({
304
+ name: names.sandbox,
305
+ handlers: {
306
+ reconcile: async (ctx) => {
307
+ await runSandbox(ctx, "reconcile");
308
+ },
309
+ arm: async (ctx, input) => {
310
+ await armSandbox(ctx, input);
311
+ },
312
+ fire: async (ctx, input) => {
313
+ if ((await ctx.get(`at:${input.timer}`)) !== input.at)
314
+ return;
315
+ ctx.clear(`at:${input.timer}`);
316
+ await runSandbox(ctx, input.timer === "retry" ? "reconcile" : input.timer);
317
+ },
318
+ },
319
+ options: serviceOptions,
320
+ });
321
+ return [session, tenant, timer, action, sandbox];
263
322
  }
264
323
  /**
265
324
  * Runs a Worker handler with a signal aborted by `stop` or by the end of the Restate
@@ -365,7 +424,7 @@ export async function listStuckInvocations(options) {
365
424
  const prefix = options.servicePrefix ?? "";
366
425
  if (!/^[A-Za-z0-9_]*$/.test(prefix))
367
426
  throw new Error(`Invalid Restate service prefix: ${prefix}`);
368
- const services = ["NylorunSession", "NylorunTenant", "NylorunTimer", "NylorunAction"].map((name) => `'${prefix}${name}'`);
427
+ const services = ["NylorunSession", "NylorunTenant", "NylorunTimer", "NylorunAction", "NylorunSandbox"].map((name) => `'${prefix}${name}'`);
369
428
  const clauses = [
370
429
  `target_service_name IN (${services.join(", ")})`,
371
430
  `status IN ('paused', 'backing-off')`,
@@ -145,7 +145,9 @@ export function createPgoutputSource(options) {
145
145
  ]);
146
146
  },
147
147
  async lag() {
148
- const { rows } = await pool.query(`SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)::text AS lag
148
+ // An idle relay confirms one byte past the last record, ahead of pg_current_wal_lsn():
149
+ // that is no lag, not -1 (the Admin status refuses a negative lag).
150
+ const { rows } = await pool.query(`SELECT GREATEST(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn), 0)::text AS lag
149
151
  FROM pg_replication_slots WHERE slot_name = $1`, [slot]);
150
152
  return rows[0]?.lag == null ? undefined : Number(rows[0].lag);
151
153
  },
@@ -0,0 +1,10 @@
1
+ import type { SandboxBackend } from "../../sandbox/types.js";
2
+ export interface LocalBackendOptions {
3
+ /** Default `/workspace`. */
4
+ readonly workspace?: string;
5
+ /** The proxy variables of the moment (the egress token rotates); default none. */
6
+ readonly proxyEnv?: () => Readonly<Record<string, string>>;
7
+ /** The base environment of commands (the engine's allowlisted one); `NYLORUN_*` is dropped. */
8
+ readonly env: Readonly<Record<string, string | undefined>>;
9
+ }
10
+ export declare function localBackend(options: LocalBackendOptions): SandboxBackend;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Local backend (F7.2): the workspace of the pod sandbox the engine runs in. The pod is the
3
+ * boundary (its own user, no capabilities, a read-only root, a NetworkPolicy that lets it reach
4
+ * only the Harness API, the gates and egress-gate), so commands run as plain child processes:
5
+ * `/bin/sh -c <command>` in `/workspace`, its own process group, killed whole on a timeout or a
6
+ * cancel. Files are read and written with `fs`. Egress goes through egress-gate: every command
7
+ * gets `HTTPS_PROXY`/`HTTP_PROXY` with the sandbox's current egress token as the proxy password
8
+ * (`proxyEnv`), and nothing of the engine's own environment (`NYLORUN_*`).
9
+ *
10
+ * One pod holds one sandbox: every key opens the same `/workspace`. Removing a workspace is the
11
+ * sandbox's reset or delete (a new volume), so `remove` and `list` do nothing here.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { accessSync, constants } from "node:fs";
15
+ import { lstat, readdir, readFile, stat, writeFile } from "node:fs/promises";
16
+ import { posix } from "node:path";
17
+ import { SANDBOX_WORKSPACE } from "@nylorun/core/define";
18
+ import { SandboxFileTooLargeError } from "../../sandbox/types.js";
19
+ /** The most of each stream kept from one command (the tools keep 30 000 characters of it). */
20
+ const STREAM_LIMIT = 4 * 1024 * 1024;
21
+ /** This process's environment without the engine's own variables. */
22
+ function baseEnv(env, workspace) {
23
+ const out = {};
24
+ for (const [name, value] of Object.entries(env))
25
+ if (value !== undefined && !name.startsWith("NYLORUN_") && !/_PROXY$/i.test(name))
26
+ out[name] = value;
27
+ return { PATH: "/usr/local/bin:/usr/bin:/bin", LANG: "C.UTF-8", TERM: "dumb", ...out, HOME: workspace };
28
+ }
29
+ /** Collects a stream, keeping at most `STREAM_LIMIT` bytes (head first). */
30
+ function collector() {
31
+ const chunks = [];
32
+ let size = 0;
33
+ let dropped = 0;
34
+ return {
35
+ push(chunk) {
36
+ if (size >= STREAM_LIMIT) {
37
+ dropped += chunk.length;
38
+ return;
39
+ }
40
+ const room = STREAM_LIMIT - size;
41
+ const kept = chunk.length > room ? chunk.subarray(0, room) : chunk;
42
+ chunks.push(kept);
43
+ size += kept.length;
44
+ dropped += chunk.length - kept.length;
45
+ },
46
+ text() {
47
+ const text = Buffer.concat(chunks).toString("utf8");
48
+ return dropped > 0 ? `${text}\n… [${dropped} bytes dropped]` : text;
49
+ },
50
+ };
51
+ }
52
+ export function localBackend(options) {
53
+ const workspace = options.workspace ?? SANDBOX_WORKSPACE;
54
+ const env = baseEnv(options.env, workspace);
55
+ const exec = (request, signal) => new Promise((resolve, reject) => {
56
+ if (signal.aborted)
57
+ return reject(signal.reason ?? new Error("Turn cancelled"));
58
+ const child = spawn("/bin/sh", ["-c", request.command], {
59
+ cwd: request.cwd,
60
+ env: { ...env, ...(options.proxyEnv?.() ?? {}) },
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ // Its own process group, so a timeout or a cancel kills everything it started.
63
+ detached: true,
64
+ });
65
+ const stdout = collector();
66
+ const stderr = collector();
67
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
68
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
69
+ let killed = false;
70
+ let timedOut = false;
71
+ const kill = () => {
72
+ if (killed || child.pid === undefined)
73
+ return;
74
+ killed = true;
75
+ try {
76
+ process.kill(-child.pid, "SIGKILL");
77
+ }
78
+ catch {
79
+ child.kill("SIGKILL");
80
+ }
81
+ };
82
+ const timer = setTimeout(() => {
83
+ timedOut = true;
84
+ kill();
85
+ }, request.timeoutMs);
86
+ const onAbort = () => kill();
87
+ signal.addEventListener("abort", onAbort, { once: true });
88
+ child.once("error", (error) => {
89
+ clearTimeout(timer);
90
+ signal.removeEventListener("abort", onAbort);
91
+ reject(error);
92
+ });
93
+ child.once("close", (code, by) => {
94
+ clearTimeout(timer);
95
+ signal.removeEventListener("abort", onAbort);
96
+ resolve({
97
+ exitCode: killed ? (timedOut ? 124 : 130) : (code ?? (by ? 128 : 1)),
98
+ stdout: stdout.text(),
99
+ stderr: stderr.text(),
100
+ killed,
101
+ timedOut,
102
+ });
103
+ });
104
+ });
105
+ const missing = (error) => error?.code === "ENOENT" || error?.code === "ENOTDIR";
106
+ const handle = {
107
+ workspace,
108
+ exec,
109
+ async readFile(path) {
110
+ try {
111
+ return await readFile(path, "utf8");
112
+ }
113
+ catch (error) {
114
+ if (missing(error))
115
+ return undefined;
116
+ throw error;
117
+ }
118
+ },
119
+ async readBytes(path, maxBytes) {
120
+ let size;
121
+ try {
122
+ size = (await stat(path)).size;
123
+ }
124
+ catch (error) {
125
+ if (missing(error))
126
+ return undefined;
127
+ throw error;
128
+ }
129
+ if (size > maxBytes)
130
+ throw new SandboxFileTooLargeError(path, size, maxBytes);
131
+ return new Uint8Array(await readFile(path));
132
+ },
133
+ async listFiles(dir, maxEntries) {
134
+ try {
135
+ if (!(await lstat(dir)).isDirectory())
136
+ return undefined;
137
+ }
138
+ catch (error) {
139
+ if (missing(error))
140
+ return undefined;
141
+ throw error;
142
+ }
143
+ const entries = [];
144
+ const walk = async (absolute, relative) => {
145
+ for (const name of (await readdir(absolute)).sort()) {
146
+ const path = posix.join(absolute, name);
147
+ const inFolder = relative === "" ? name : `${relative}/${name}`;
148
+ const info = await lstat(path);
149
+ if (info.isSymbolicLink())
150
+ continue;
151
+ if (info.isDirectory()) {
152
+ if (!(await walk(path, inFolder)))
153
+ return false;
154
+ }
155
+ else if (info.isFile()) {
156
+ entries.push({ path: inFolder, size: info.size });
157
+ if (entries.length > maxEntries)
158
+ return false;
159
+ }
160
+ }
161
+ return true;
162
+ };
163
+ const complete = await walk(dir, "");
164
+ return { entries, truncated: !complete };
165
+ },
166
+ async writeFile(path, content) {
167
+ await writeFile(path, content, "utf8");
168
+ },
169
+ async stop() { },
170
+ };
171
+ return {
172
+ name: "local",
173
+ isolation: "container",
174
+ async probe() {
175
+ const base = { name: "local", isolation: "container" };
176
+ try {
177
+ accessSync(workspace, constants.W_OK);
178
+ return { ...base, available: true, reason: `the pod sandbox's ${workspace}` };
179
+ }
180
+ catch (error) {
181
+ return {
182
+ ...base,
183
+ available: false,
184
+ reason: `${workspace} is not writable: ${error instanceof Error ? error.message : String(error)}`,
185
+ };
186
+ }
187
+ },
188
+ unmet: () => undefined,
189
+ open: async () => handle,
190
+ async remove() { },
191
+ async list() {
192
+ return [];
193
+ },
194
+ };
195
+ }
@@ -4,8 +4,9 @@
4
4
  * It is not a VM boundary; it exists so the first run and CI work on any machine.
5
5
  */
6
6
  import { mkdirSync, readdirSync, rmSync } from "node:fs";
7
- import { join } from "node:path";
7
+ import { join, posix } from "node:path";
8
8
  import { SANDBOX_WORKSPACE } from "@nylorun/core/define";
9
+ import { SandboxFileTooLargeError } from "../../sandbox/types.js";
9
10
  const ENV = Object.freeze({
10
11
  PATH: "/usr/local/bin:/usr/bin:/bin",
11
12
  HOME: SANDBOX_WORKSPACE,
@@ -104,6 +105,40 @@ export function virtualBackend(options) {
104
105
  return undefined;
105
106
  return fs.readFile(path, "utf8");
106
107
  },
108
+ async readBytes(path, maxBytes) {
109
+ if (!(await fs.exists(path)))
110
+ return undefined;
111
+ const stat = await fs.stat(path);
112
+ if (stat.size > maxBytes)
113
+ throw new SandboxFileTooLargeError(path, stat.size, maxBytes);
114
+ return fs.readFileBuffer(path);
115
+ },
116
+ async listFiles(dir, maxEntries) {
117
+ if (!(await fs.exists(dir)) || !(await fs.lstat(dir)).isDirectory)
118
+ return undefined;
119
+ const entries = [];
120
+ const walk = async (absolute, relative) => {
121
+ for (const name of (await fs.readdir(absolute)).sort()) {
122
+ const path = posix.join(absolute, name);
123
+ const inFolder = relative === "" ? name : `${relative}/${name}`;
124
+ const stat = await fs.lstat(path);
125
+ if (stat.isSymbolicLink)
126
+ continue;
127
+ if (stat.isDirectory) {
128
+ if (!(await walk(path, inFolder)))
129
+ return false;
130
+ }
131
+ else if (stat.isFile) {
132
+ entries.push({ path: inFolder, size: stat.size });
133
+ if (entries.length > maxEntries)
134
+ return false;
135
+ }
136
+ }
137
+ return true;
138
+ };
139
+ const complete = await walk(dir, "");
140
+ return { entries, truncated: !complete };
141
+ },
107
142
  async writeFile(path, content) {
108
143
  await fs.writeFile(path, content, "utf8");
109
144
  },