@nylorun/runtime 0.15.2-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 (219) hide show
  1. package/CHANGELOG.md +76 -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/sandbox/local.d.ts +10 -0
  6. package/dist/adapters/sandbox/local.js +195 -0
  7. package/dist/adapters/sandbox/virtual.js +36 -1
  8. package/dist/admin-openapi.json +37 -3
  9. package/dist/api/a2a/tasks.js +6 -1
  10. package/dist/api/ag-ui/history.js +6 -1
  11. package/dist/api/ag-ui/routes.js +5 -1
  12. package/dist/api/components.d.ts +2673 -655
  13. package/dist/api/components.js +17 -1
  14. package/dist/api/gate/keys.d.ts +4 -3
  15. package/dist/api/gate/routes.d.ts +19 -3
  16. package/dist/api/gate/routes.js +162 -53
  17. package/dist/api/http/app.js +4 -0
  18. package/dist/api/http/define.d.ts +10 -0
  19. package/dist/api/http/define.js +2 -1
  20. package/dist/api/http/routes/artifacts.d.ts +11 -0
  21. package/dist/api/http/routes/artifacts.js +518 -0
  22. package/dist/api/http/routes/sandboxes.d.ts +9 -0
  23. package/dist/api/http/routes/sandboxes.js +152 -0
  24. package/dist/api/http/routes/sessions.js +3 -1
  25. package/dist/api/http/routes/tenant.js +36 -2
  26. package/dist/artifacts/config.d.ts +23 -0
  27. package/dist/artifacts/config.js +27 -0
  28. package/dist/artifacts/export.d.ts +8 -0
  29. package/dist/artifacts/export.js +205 -0
  30. package/dist/artifacts/files.d.ts +36 -0
  31. package/dist/artifacts/files.js +45 -0
  32. package/dist/artifacts/folders.d.ts +60 -0
  33. package/dist/artifacts/folders.js +209 -0
  34. package/dist/artifacts/links.d.ts +28 -0
  35. package/dist/artifacts/links.js +92 -0
  36. package/dist/artifacts/media-types.d.ts +13 -0
  37. package/dist/artifacts/media-types.js +80 -0
  38. package/dist/artifacts/parts.d.ts +34 -0
  39. package/dist/artifacts/parts.js +38 -0
  40. package/dist/artifacts/service.d.ts +74 -0
  41. package/dist/artifacts/service.js +301 -0
  42. package/dist/artifacts/workspace.d.ts +60 -0
  43. package/dist/artifacts/workspace.js +57 -0
  44. package/dist/artifacts/zip.d.ts +10 -0
  45. package/dist/artifacts/zip.js +126 -0
  46. package/dist/blob/fs.d.ts +13 -0
  47. package/dist/blob/fs.js +194 -0
  48. package/dist/blob/index.d.ts +9 -0
  49. package/dist/blob/index.js +9 -0
  50. package/dist/blob/s3.d.ts +42 -0
  51. package/dist/blob/s3.js +366 -0
  52. package/dist/blob/types.d.ts +108 -0
  53. package/dist/blob/types.js +85 -0
  54. package/dist/core/sandbox-routes.d.ts +2 -2
  55. package/dist/core/sandbox-routes.js +4 -3
  56. package/dist/execution/memory.d.ts +11 -1
  57. package/dist/execution/memory.js +77 -3
  58. package/dist/execution/types.d.ts +54 -1
  59. package/dist/execution/types.js +31 -0
  60. package/dist/gates/contract.d.ts +14 -13
  61. package/dist/gates/contract.js +13 -12
  62. package/dist/gates/egress.d.ts +72 -0
  63. package/dist/gates/egress.js +319 -0
  64. package/dist/gates/handler.d.ts +3 -0
  65. package/dist/gates/handler.js +3 -2
  66. package/dist/gates/http-client.d.ts +3 -2
  67. package/dist/gates/http-client.js +17 -6
  68. package/dist/gates/in-process.d.ts +13 -1
  69. package/dist/gates/in-process.js +11 -0
  70. package/dist/gates/inflight.d.ts +23 -4
  71. package/dist/gates/inflight.js +18 -4
  72. package/dist/gates/model-gate.d.ts +6 -2
  73. package/dist/gates/tool-calls.d.ts +9 -5
  74. package/dist/gates/tool-calls.js +6 -6
  75. package/dist/gates/tool-client.d.ts +9 -2
  76. package/dist/gates/tool-client.js +56 -22
  77. package/dist/gates/tool-contract.d.ts +12 -7
  78. package/dist/gates/tool-contract.js +10 -4
  79. package/dist/gates/tool-gate.d.ts +1 -0
  80. package/dist/harness/calls.d.ts +52 -0
  81. package/dist/harness/calls.js +113 -0
  82. package/dist/harness/executors.d.ts +22 -0
  83. package/dist/harness/executors.js +96 -0
  84. package/dist/harness/main.d.ts +6 -0
  85. package/dist/harness/main.js +103 -0
  86. package/dist/harness/pod.d.ts +21 -0
  87. package/dist/harness/pod.js +119 -0
  88. package/dist/harness/run-tokens.d.ts +18 -0
  89. package/dist/harness/run-tokens.js +15 -0
  90. package/dist/harness/service.d.ts +46 -0
  91. package/dist/harness/service.js +179 -0
  92. package/dist/harness/ws-client.d.ts +43 -0
  93. package/dist/harness/ws-client.js +122 -0
  94. package/dist/harness/ws-port.d.ts +22 -0
  95. package/dist/harness/ws-port.js +78 -0
  96. package/dist/harness-api/in-process.d.ts +24 -0
  97. package/dist/harness-api/in-process.js +119 -0
  98. package/dist/harness-api/record.d.ts +44 -0
  99. package/dist/harness-api/record.js +250 -0
  100. package/dist/harness-api/renew.d.ts +37 -0
  101. package/dist/harness-api/renew.js +77 -0
  102. package/dist/harness-api/server.d.ts +104 -0
  103. package/dist/harness-api/server.js +408 -0
  104. package/dist/harness-api/start.d.ts +36 -0
  105. package/dist/harness-api/start.js +118 -0
  106. package/dist/harness-api/workspace.d.ts +53 -0
  107. package/dist/harness-api/workspace.js +201 -0
  108. package/dist/harness-api/ws-server.d.ts +34 -0
  109. package/dist/harness-api/ws-server.js +227 -0
  110. package/dist/host/app.js +23 -2
  111. package/dist/host/gates.d.ts +27 -2
  112. package/dist/host/gates.js +46 -1
  113. package/dist/host/http.d.ts +4 -1
  114. package/dist/host/http.js +6 -1
  115. package/dist/host/main.js +135 -27
  116. package/dist/host/stack-config.d.ts +101 -1
  117. package/dist/host/stack-config.js +199 -8
  118. package/dist/mcp/snapshot.d.ts +8 -1
  119. package/dist/mcp/snapshot.js +28 -0
  120. package/dist/media.d.ts +5 -24
  121. package/dist/media.js +5 -0
  122. package/dist/model/pi-model.d.ts +6 -2
  123. package/dist/model/pi-model.js +39 -22
  124. package/dist/node/index.d.ts +1 -1
  125. package/dist/node/index.js +1 -1
  126. package/dist/openapi.json +15245 -8850
  127. package/dist/record/index.d.ts +1 -0
  128. package/dist/record/index.js +1 -0
  129. package/dist/record/sandbox.d.ts +25 -0
  130. package/dist/record/sandbox.js +43 -0
  131. package/dist/sandbox/capability.d.ts +3 -0
  132. package/dist/sandbox/capability.js +10 -0
  133. package/dist/sandbox/egress-token.d.ts +26 -0
  134. package/dist/sandbox/egress-token.js +8 -0
  135. package/dist/sandbox/join.d.ts +37 -0
  136. package/dist/sandbox/join.js +165 -0
  137. package/dist/sandbox/manager.d.ts +99 -34
  138. package/dist/sandbox/manager.js +216 -84
  139. package/dist/sandbox/placement.d.ts +27 -0
  140. package/dist/sandbox/placement.js +44 -0
  141. package/dist/sandbox/pods/client.d.ts +90 -0
  142. package/dist/sandbox/pods/client.js +79 -0
  143. package/dist/sandbox/pods/lifecycle.d.ts +91 -0
  144. package/dist/sandbox/pods/lifecycle.js +178 -0
  145. package/dist/sandbox/pods/name.d.ts +1 -0
  146. package/dist/sandbox/pods/name.js +28 -0
  147. package/dist/sandbox/pods/network-gate.d.ts +25 -0
  148. package/dist/sandbox/pods/network-gate.js +71 -0
  149. package/dist/sandbox/pods/reconcile.d.ts +11 -0
  150. package/dist/sandbox/pods/reconcile.js +152 -0
  151. package/dist/sandbox/pods/spec.d.ts +45 -0
  152. package/dist/sandbox/pods/spec.js +62 -0
  153. package/dist/sandbox/records.d.ts +29 -0
  154. package/dist/sandbox/records.js +79 -0
  155. package/dist/sandbox/resolve.d.ts +5 -0
  156. package/dist/sandbox/resolve.js +3 -2
  157. package/dist/sandbox/session-sandbox.js +7 -5
  158. package/dist/sandbox/share.d.ts +11 -0
  159. package/dist/sandbox/share.js +10 -0
  160. package/dist/sandbox/tenant-config.d.ts +16 -1
  161. package/dist/sandbox/tenant-config.js +9 -0
  162. package/dist/sandbox/types.d.ts +29 -1
  163. package/dist/sandbox/types.js +13 -1
  164. package/dist/store/postgres/drizzle/0004_sandbox_resources.sql +20 -0
  165. package/dist/store/postgres/drizzle/0005_artifacts.sql +27 -0
  166. package/dist/store/postgres/drizzle/0006_folder_artifacts.sql +12 -0
  167. package/dist/store/postgres/drizzle/0007_sandbox_pods.sql +14 -0
  168. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  169. package/dist/store/postgres/record-writer.d.ts +6 -1
  170. package/dist/store/postgres/record-writer.js +20 -2
  171. package/dist/store/postgres/schema.d.ts +894 -0
  172. package/dist/store/postgres/schema.js +130 -2
  173. package/dist/store/postgres/store.js +289 -4
  174. package/dist/store/types.d.ts +143 -6
  175. package/dist/streams/control.d.ts +2 -0
  176. package/dist/streams/control.js +5 -0
  177. package/dist/streams/types.d.ts +10 -1
  178. package/dist/tenant/advance.js +218 -160
  179. package/dist/tenant/artifact-tool.d.ts +5 -0
  180. package/dist/tenant/artifact-tool.js +119 -0
  181. package/dist/tenant/commands.js +30 -5
  182. package/dist/tenant/context.d.ts +64 -5
  183. package/dist/tenant/effects.d.ts +19 -13
  184. package/dist/tenant/effects.js +5 -382
  185. package/dist/tenant/history.d.ts +13 -23
  186. package/dist/tenant/history.js +14 -62
  187. package/dist/tenant/host-token.d.ts +92 -0
  188. package/dist/tenant/host-token.js +179 -0
  189. package/dist/tenant/module.js +1 -0
  190. package/dist/tenant/paths.d.ts +2 -1
  191. package/dist/tenant/paths.js +3 -1
  192. package/dist/tenant/reset.d.ts +5 -2
  193. package/dist/tenant/reset.js +22 -2
  194. package/dist/tenant/run-grants.d.ts +49 -0
  195. package/dist/tenant/run-grants.js +62 -0
  196. package/dist/tenant/run-token.d.ts +70 -0
  197. package/dist/tenant/run-token.js +138 -0
  198. package/dist/tenant/runtime.d.ts +38 -0
  199. package/dist/tenant/runtime.js +120 -32
  200. package/dist/tenant/sandboxes.d.ts +119 -0
  201. package/dist/tenant/sandboxes.js +551 -0
  202. package/dist/tenant/session.d.ts +2 -13
  203. package/dist/tenant/session.js +4 -28
  204. package/dist/tenant/sessions.d.ts +5 -0
  205. package/dist/tenant/sessions.js +28 -1
  206. package/dist/tenant/status.d.ts +8 -3
  207. package/dist/tenant/status.js +5 -0
  208. package/dist/tenant/streams.d.ts +7 -0
  209. package/dist/tenant/streams.js +24 -1
  210. package/dist/tenant/sweep.js +37 -4
  211. package/dist/tenant/tokens.js +9 -2
  212. package/dist/tenant/types.d.ts +18 -1
  213. package/dist/tenant/worker.d.ts +11 -5
  214. package/dist/tenant/worker.js +12 -6
  215. package/dist/version.d.ts +1 -1
  216. package/dist/version.js +1 -1
  217. package/package.json +13 -10
  218. package/dist/adapters/media.d.ts +0 -24
  219. package/dist/adapters/media.js +0 -111
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Run tokens (F5, gate trust): the ES256 JWT core mints for one advance's lease of a session.
3
+ * It is the only credential of a model call at the gates, and the credential of a session's
4
+ * remote MCP calls. The gates take session, turn and agent from it, never from the body, and
5
+ * check on every call that the lease it names is still the session's (`staleRun`).
6
+ *
7
+ * Signed by the keys service with the Tenant's current signing key, like subject and delivery
8
+ * tokens, and told apart from them by `typ` and `aud`. Internal: not in `openapi.json`. A token
9
+ * lives `RUN_TOKEN_TTL_SECONDS`, no longer than a delivery token, so a key rotation never
10
+ * revokes a live one (`signing-keys.ts`).
11
+ *
12
+ * ```text
13
+ * header { alg: ES256, typ: nylorun-run+jwt, kid }
14
+ * claims { iss: urn:nylorun:tenant:<id>, aud: nylorun-gates, sub: <sessionId>,
15
+ * trn: <turnId>, agt: <root agentId>, epc: <lease epoch>, iat, exp, jti }
16
+ * ```
17
+ */
18
+ import { randomUUID } from "node:crypto";
19
+ import { errors, importJWK, jwtVerify } from "jose";
20
+ import { subjectTokenIssuer } from "@nylorun/core/contracts";
21
+ import { CLOCK_TOLERANCE_SECONDS, tokenKeyId } from "./jwt.js";
22
+ export const RUN_TOKEN_TYP = "nylorun-run+jwt";
23
+ export const RUN_TOKEN_AUD = "nylorun-gates";
24
+ /** How long a run token lives. */
25
+ export const RUN_TOKEN_TTL_SECONDS = 15 * 60;
26
+ /** The lease heartbeat re-mints a token with less than this left. */
27
+ export const RUN_TOKEN_RENEW_SECONDS = 5 * 60;
28
+ /** Allowed spread between `iat` and `exp` beyond the lifetime. */
29
+ const LIFETIME_SLACK_SECONDS = 60;
30
+ /**
31
+ * Mints the run token of `lease` over `session`'s active turn. Throws when the session has
32
+ * no active turn: there is no run to name.
33
+ */
34
+ export async function mintRunToken(signer, lease, session) {
35
+ const turnId = session.activeTurnId;
36
+ if (!turnId)
37
+ throw new Error(`Session ${lease.sessionId} has no active turn to mint a run token for`);
38
+ const tenantId = signer.config.tenantId;
39
+ const iat = Math.floor(Date.now() / 1000);
40
+ const exp = iat + RUN_TOKEN_TTL_SECONDS;
41
+ // Signed by the keys service (F4.2): this process never holds the private key.
42
+ const { token } = await signer.keys.sign({
43
+ typ: RUN_TOKEN_TYP,
44
+ claims: {
45
+ iss: subjectTokenIssuer(tenantId),
46
+ aud: RUN_TOKEN_AUD,
47
+ sub: lease.sessionId,
48
+ trn: turnId,
49
+ agt: session.agentId,
50
+ epc: lease.epoch,
51
+ iat,
52
+ exp,
53
+ jti: randomUUID(),
54
+ },
55
+ });
56
+ return {
57
+ token,
58
+ claims: {
59
+ tenantId,
60
+ sessionId: lease.sessionId,
61
+ turnId,
62
+ agentId: session.agentId,
63
+ epoch: lease.epoch,
64
+ expiresAt: exp * 1000,
65
+ },
66
+ };
67
+ }
68
+ /**
69
+ * Verifies a run token of Tenant `tenantId` against the public keys in `store`: everything a
70
+ * forger controls (size, shape, header) is checked before any key is read, then the
71
+ * signature, `iss`, `aud`, `typ`, lifetime and claims. A revoked key refuses even a token it
72
+ * signed while it was live.
73
+ */
74
+ export async function verifyRunToken(store, tenantId, raw, cache = new Map()) {
75
+ const refused = (reason) => ({ ok: false, reason: `run_token_${reason}` });
76
+ const checked = tokenKeyId(raw, RUN_TOKEN_TYP);
77
+ if ("refused" in checked)
78
+ return refused(checked.refused);
79
+ const { kid } = checked;
80
+ const row = await store.tx((t) => t.signingKey(kid));
81
+ if (!row)
82
+ return refused("key_unknown");
83
+ let key = cache.get(kid);
84
+ if (!key) {
85
+ key = await importJWK({ ...JSON.parse(row.publicJwk), alg: "ES256" }, "ES256");
86
+ cache.set(kid, key);
87
+ }
88
+ let payload;
89
+ try {
90
+ const verified = await jwtVerify(raw, key, {
91
+ algorithms: ["ES256"],
92
+ issuer: subjectTokenIssuer(tenantId),
93
+ audience: RUN_TOKEN_AUD,
94
+ typ: RUN_TOKEN_TYP,
95
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
96
+ requiredClaims: ["sub", "iat", "exp", "jti"],
97
+ });
98
+ payload = verified.payload;
99
+ }
100
+ catch (error) {
101
+ return refused(error instanceof errors.JWTExpired ? "expired" : "invalid");
102
+ }
103
+ if (row.state === "revoked")
104
+ return refused("key_revoked");
105
+ const { sub, trn, agt, epc, iat, exp } = payload;
106
+ if (typeof sub !== "string" ||
107
+ sub === "" ||
108
+ typeof trn !== "string" ||
109
+ trn === "" ||
110
+ typeof agt !== "string" ||
111
+ agt === "" ||
112
+ typeof epc !== "number" ||
113
+ !Number.isSafeInteger(epc) ||
114
+ epc < 1 ||
115
+ exp - iat > RUN_TOKEN_TTL_SECONDS + LIFETIME_SLACK_SECONDS)
116
+ return refused("claims");
117
+ return {
118
+ ok: true,
119
+ claims: { tenantId, sessionId: sub, turnId: trn, agentId: agt, epoch: epc, expiresAt: exp * 1000 },
120
+ };
121
+ }
122
+ /**
123
+ * Why calls under `claims` are stale, or undefined while its lease is still the session's
124
+ * (G4): the session's epoch is the token's, its active turn is the token's, and it is not
125
+ * cancelled. One indexed read of the session row.
126
+ */
127
+ export async function staleRun(store, claims) {
128
+ const state = await store.tx((t) => t.runState(claims.sessionId));
129
+ if (!state)
130
+ return "the session is gone";
131
+ if (state.epoch !== claims.epoch)
132
+ return "another advance owns the session now";
133
+ if (state.activeTurnId !== claims.turnId)
134
+ return "the turn has ended";
135
+ if (state.status === "cancelled")
136
+ return "the turn was cancelled";
137
+ return undefined;
138
+ }
@@ -8,6 +8,12 @@ import { type StreamsStatus } from "./streams.js";
8
8
  import { type TenantExecution, type TenantWorker } from "./worker.js";
9
9
  import { type ToolGate } from "../gates/tool-gate.js";
10
10
  import { type Keys } from "../keys/keys.js";
11
+ import type { RunGrants } from "./run-grants.js";
12
+ import { type BlobStore } from "../blob/index.js";
13
+ import type { HarnessChannel, MemoryPortsOptions } from "@nylorun/core/harness-api";
14
+ import { type HarnessPeer } from "../harness-api/server.js";
15
+ import { type HostAuthority } from "../sandbox/join.js";
16
+ import type { TenantPods } from "./context.js";
11
17
  /** TENANTS-CCR: test/injection hooks until TenantConfig gains them. */
12
18
  export type TenantOpenHooks = {
13
19
  modelProvider?: ModelProvider;
@@ -26,6 +32,11 @@ export type TenantOpenHooks = {
26
32
  * process never reads, creates or holds the vault key. Without one, it does them here.
27
33
  */
28
34
  keys?: Keys;
35
+ /**
36
+ * The run grants the gates service's clients read (F5): each advance mints its session's
37
+ * run token into them. Give the same object to `modelGate` and `toolGate`.
38
+ */
39
+ runGrants?: RunGrants;
29
40
  vaultKek?: Buffer | string | null;
30
41
  /** When true, create the KEK file on first vault write (tests / new Tenants). */
31
42
  createKekIfMissing?: boolean;
@@ -50,6 +61,28 @@ export type TenantOpenHooks = {
50
61
  hostRelay?: boolean;
51
62
  /** How long a retired stream basin is kept after a reset (tests). Default 60 s. */
52
63
  retireGraceMs?: number;
64
+ /**
65
+ * The Object store, owned by the caller (the Host's `s3` BlobStore, D35). Without one the
66
+ * Tenant keeps blobs on disk under `paths.blobs` (the `fs` adapter: embedding, tests).
67
+ */
68
+ blobs?: BlobStore;
69
+ /**
70
+ * The Tenant's harness (F6.1, F6.2): `memory` (default) runs one in this process over a memory
71
+ * channel, with the Tenant's MCP pool and SandboxManager; `json` the same through JSON with
72
+ * every frame validated (tests). `remote` runs none: harnesses attach with
73
+ * `TenantHandle.attachHarness` (the Harness API listener, `NYLORUN_HARNESS=remote`), and the
74
+ * one that declares `workspace` holds the workspaces; this process has no MCP pool and no
75
+ * SandboxManager. `ws` is `remote` with a harness service in this process over a loopback
76
+ * WebSocket (tests).
77
+ */
78
+ harness?: "memory" | "json" | "ws" | "remote";
79
+ /** Sees each frame of the in-process harness's channel (tests and benchmarks). */
80
+ harnessTap?: MemoryPortsOptions["tap"];
81
+ /**
82
+ * Sandbox pods (F7.2): the sandboxes service (`NYLORUN_SANDBOXES_URL`) and the Runtime image
83
+ * pods copy the engine from. Without it, kind `pod` is `sandbox_unavailable`.
84
+ */
85
+ pods?: TenantPods;
53
86
  } & OpenedTenant;
54
87
  export declare class TenantRuntime implements TenantHandle {
55
88
  private readonly ctx;
@@ -59,7 +92,11 @@ export declare class TenantRuntime implements TenantHandle {
59
92
  private readonly detach;
60
93
  /** How long close waits for running advances: the execution's advance grace period. */
61
94
  private readonly closeGraceMs;
95
+ private readonly harness?;
96
+ private readonly hosts?;
62
97
  private constructor();
98
+ /** The Tenant's side of a sandbox pod's join (F7.2). */
99
+ hostAuthority(): HostAuthority | undefined;
63
100
  static open(config: TenantConfig, hooks: TenantOpenHooks): Promise<TenantRuntime>;
64
101
  fetch(request: Request, node: NodeBindings): Promise<Response>;
65
102
  authorize(sessionId: string, request: {
@@ -80,6 +117,7 @@ export declare class TenantRuntime implements TenantHandle {
80
117
  streamsStatus(): Promise<StreamsStatus>;
81
118
  /** Adds a callback to the Tenant sweep. Returns a function that removes it. */
82
119
  onSweep(hook: () => Promise<void>): () => void;
120
+ attachHarness(channel: HarnessChannel, peer: HarnessPeer): () => void;
83
121
  close(): Promise<void>;
84
122
  }
85
123
  /**
@@ -7,6 +7,7 @@ import { SigningKeys } from "./signing-keys.js";
7
7
  import { VaultService } from "../vault/service.js";
8
8
  import { McpPool } from "../mcp/pool.js";
9
9
  import { SandboxManager } from "../sandbox/manager.js";
10
+ import { storeSandboxRecords } from "../sandbox/records.js";
10
11
  import { defaultSandboxBackends } from "../sandbox/select.js";
11
12
  import { MemoryExecution } from "../execution/memory.js";
12
13
  import { openError } from "./cause.js";
@@ -22,6 +23,14 @@ import { authorize } from "./effects.js";
22
23
  import { inProcessToolGate } from "../gates/tool-gate.js";
23
24
  import { inProcessKeys } from "../keys/keys.js";
24
25
  import { tenantApi } from "../api/http/app.js";
26
+ import { createFsBlobStore } from "../blob/index.js";
27
+ import { createHarnessApiServer, } from "../harness-api/server.js";
28
+ import { harnessStatusOf } from "./status.js";
29
+ import { startInProcessHarness, startLoopbackHarness, } from "../harness-api/in-process.js";
30
+ import { localWorkspace, remoteWorkspace, withPodWorkspaces, } from "../harness-api/workspace.js";
31
+ import { hostAuthority } from "../sandbox/join.js";
32
+ import { reconcileSandbox } from "../sandbox/pods/reconcile.js";
33
+ import { sandboxWorkspaceReader } from "../artifacts/workspace.js";
25
34
  /** Default ownership lease of an advance; the heartbeat renews it every third. */
26
35
  const DEFAULT_OWNER_LEASE_MS = 30_000;
27
36
  function validateConfig(config) {
@@ -45,16 +54,24 @@ export class TenantRuntime {
45
54
  worker;
46
55
  detach;
47
56
  closeGraceMs;
57
+ harness;
58
+ hosts;
48
59
  constructor(ctx, envelope,
49
60
  /** This Tenant's Worker handlers, as registered with the execution. */
50
61
  worker, detach,
51
62
  /** How long close waits for running advances: the execution's advance grace period. */
52
- closeGraceMs) {
63
+ closeGraceMs, harness, hosts) {
53
64
  this.ctx = ctx;
54
65
  this.envelope = envelope;
55
66
  this.worker = worker;
56
67
  this.detach = detach;
57
68
  this.closeGraceMs = closeGraceMs;
69
+ this.harness = harness;
70
+ this.hosts = hosts;
71
+ }
72
+ /** The Tenant's side of a sandbox pod's join (F7.2). */
73
+ hostAuthority() {
74
+ return this.ctx.closing || this.ctx.closed ? undefined : this.hosts;
58
75
  }
59
76
  static async open(config, hooks) {
60
77
  const envelope = hooks.envelope;
@@ -69,6 +86,7 @@ export class TenantRuntime {
69
86
  const store = hooks.store;
70
87
  let wired;
71
88
  let detach;
89
+ let harness;
72
90
  try {
73
91
  // With the keys service (F4.2) the key lives in the gateway: this process never reads,
74
92
  // creates or holds it, and the gateway reports a missing key on its readiness.
@@ -108,31 +126,63 @@ export class TenantRuntime {
108
126
  const toolGate = hooks.toolGate ?? inProcessToolGate(config.delivery ?? {});
109
127
  const signingKeys = new SigningKeys({ tenantId: config.tenantId, kek: ensureKek });
110
128
  const keys = hooks.keys ?? inProcessKeys({ store: opened, vault, signingKeys, kek: ensureKek });
111
- const mcp = new McpPool({
112
- pluginData: paths.pluginData,
113
- childEnv: config.childEnv,
114
- authorize: (sessionId, request) => authorize(ctx, sessionId, request),
115
- ...(toolGate.openMcp ? { openRemote: (server) => toolGate.openMcp(server) } : {}),
116
- });
129
+ const harnessMode = hooks.harness ?? "memory";
130
+ const inProcess = harnessMode === "memory" || harnessMode === "json";
131
+ const preference = seededBackend ?? config.sandbox.backend;
132
+ // With a harness in this process, the Tenant's MCP pool and SandboxManager are its
133
+ // executors; otherwise the harness holds both (F6.2).
134
+ const mcp = inProcess
135
+ ? new McpPool({
136
+ pluginData: paths.pluginData,
137
+ childEnv: config.childEnv,
138
+ authorize: (sessionId, request) => authorize(ctx, sessionId, request),
139
+ ...(toolGate.openMcp ? { openRemote: (server) => toolGate.openMcp(server) } : {}),
140
+ })
141
+ : undefined;
117
142
  const ephemeral = config.mode === "ephemeral";
118
- const sandbox = new SandboxManager({
119
- scope: config.tenantId,
120
- store: opened,
121
- backends: config.sandbox.backends ??
122
- defaultSandboxBackends({ root: paths.sandboxes }),
123
- preference: seededBackend ?? config.sandbox.backend,
124
- ephemeral,
125
- emit: async (sessionId, turnId, type, payload) => {
126
- if (ctx.closed)
127
- return;
128
- try {
129
- await opened.tx((t) => t.event(sessionId, turnId, type, payload));
130
- }
131
- catch {
132
- /* the session is gone (reset) or the store closed */
133
- }
134
- },
135
- });
143
+ const manager = inProcess
144
+ ? new SandboxManager({
145
+ scope: config.tenantId,
146
+ records: storeSandboxRecords(opened),
147
+ backends: config.sandbox.backends ??
148
+ defaultSandboxBackends({ root: paths.sandboxes }),
149
+ preference,
150
+ ephemeral,
151
+ emit: async (sessionId, turnId, type, payload) => {
152
+ if (ctx.closed)
153
+ return;
154
+ try {
155
+ await opened.tx((t) => t.event(sessionId, turnId, type, payload));
156
+ }
157
+ catch {
158
+ /* the session is gone (reset) or the store closed */
159
+ }
160
+ },
161
+ })
162
+ : undefined;
163
+ let harnessServer;
164
+ const workspaces = manager
165
+ ? localWorkspace(manager)
166
+ : remoteWorkspace({
167
+ get server() {
168
+ return harnessServer;
169
+ },
170
+ store: opened,
171
+ logger: config.logger,
172
+ preference,
173
+ });
174
+ // A pod sandbox's workspace is in its pod, served by its engine (F7.2).
175
+ const sandbox = hooks.pods
176
+ ? withPodWorkspaces(workspaces, {
177
+ get server() {
178
+ return harnessServer;
179
+ },
180
+ store: opened,
181
+ logger: config.logger,
182
+ preference,
183
+ isPod: async (sandboxId) => (await opened.tx((t) => t.sandboxResource(sandboxId)))?.kind === "pod",
184
+ })
185
+ : workspaces;
136
186
  const useVaultModel = config.model.kind === "vault";
137
187
  let modelProvider;
138
188
  if (hooks.modelProvider)
@@ -143,6 +193,7 @@ export class TenantRuntime {
143
193
  modelProvider = toolFixtureModel();
144
194
  else
145
195
  modelProvider = scriptedModel();
196
+ const blobs = hooks.blobs ?? createFsBlobStore({ root: paths.blobs });
146
197
  // With the gates service the loop never reads a model credential: only a Runtime
147
198
  // without one (embedding, the ephemeral Runtime, tests) reads it here.
148
199
  const modelGate = hooks.modelGate ??
@@ -151,40 +202,47 @@ export class TenantRuntime {
151
202
  kek: ensureKek,
152
203
  root: paths.home,
153
204
  logger: config.logger,
205
+ blobs,
154
206
  ...(config.modelCall ? { settings: config.modelCall } : {}),
155
207
  });
156
208
  const sessionStreams = createSessionStreams();
209
+ const workers = hooks.execution?.workers ?? new TenantWorkers();
157
210
  const local = hooks.execution
158
211
  ? undefined
159
212
  : new MemoryExecution({
160
213
  sweepIntervalMs: config.sweepIntervalMs ?? Math.min(config.leaseMs ?? 30_000, 5000),
214
+ // Close's `detach` step waits no longer for running work than for its advances.
215
+ stopGraceMs: workers.graceMs,
161
216
  onError: (error) => config.logger.error("tenant execution failed", {
162
217
  message: error instanceof Error ? error.message : String(error),
163
218
  }),
164
219
  });
165
- const { execution, workers } = hooks.execution ?? {
166
- execution: local,
167
- workers: new TenantWorkers(),
168
- };
220
+ const execution = hooks.execution?.execution ?? local;
169
221
  const sweepHooks = new Set();
170
222
  ctx = {
171
223
  config,
172
224
  envelope,
173
225
  store: opened,
174
226
  vault,
175
- mcp,
227
+ ...(mcp ? { mcp } : {}),
176
228
  sandbox,
177
229
  flowLimits,
178
230
  modelProvider,
179
231
  useVaultModel,
180
232
  modelGate,
181
233
  toolGate,
234
+ get harness() {
235
+ return harnessServer;
236
+ },
182
237
  closing: false,
183
238
  closed: false,
184
239
  work: createWorkState(),
185
240
  sessionStreams,
186
241
  signingKeys,
187
242
  keys,
243
+ blobs,
244
+ workspaces: sandboxWorkspaceReader(sandbox),
245
+ ...(hooks.runGrants ? { runGrants: hooks.runGrants } : {}),
188
246
  workerId: hooks.workerId ?? WORKER_ID,
189
247
  ownerLeaseMs: config.ownerLeaseMs ?? DEFAULT_OWNER_LEASE_MS,
190
248
  wake: async (sessionId, wake) => {
@@ -198,6 +256,12 @@ export class TenantRuntime {
198
256
  return;
199
257
  await execution.deliver(config.tenantId, actionId);
200
258
  },
259
+ ...(hooks.pods ? { pods: hooks.pods } : {}),
260
+ sandboxSignal: async (sandboxId, signal) => {
261
+ if (ctx.closing || ctx.closed || !execution.sandbox)
262
+ return;
263
+ await execution.sandbox(config.tenantId, sandboxId, signal);
264
+ },
201
265
  ...(hooks.execution?.stuckInvocations
202
266
  ? {
203
267
  stuckInvocations: () => hooks.execution.stuckInvocations(config.tenantId),
@@ -209,6 +273,16 @@ export class TenantRuntime {
209
273
  return () => sweepHooks.delete(hook);
210
274
  },
211
275
  };
276
+ harnessServer = createHarnessApiServer(ctx, { sandboxPreference: preference });
277
+ if (mcp && manager)
278
+ harness = await startInProcessHarness(ctx, { mcp, sandbox: manager }, {
279
+ json: harnessMode === "json",
280
+ ...(hooks.harnessTap ? { tap: hooks.harnessTap } : {}),
281
+ });
282
+ else if (harnessMode === "ws")
283
+ harness = await startLoopbackHarness(ctx, {
284
+ ...(config.sandbox.backends ? { sandboxBackends: config.sandbox.backends } : {}),
285
+ });
212
286
  wired = await wireStreams(ctx, {
213
287
  store: opened,
214
288
  streams: hooks.streams ?? new MemoryStreams(),
@@ -223,6 +297,7 @@ export class TenantRuntime {
223
297
  advance: (sessionId, signal) => advance(ctx, sessionId, signal),
224
298
  deliver: (actionId, signal) => deliverAction(ctx, actionId, signal),
225
299
  sweep: () => sweep(ctx),
300
+ sandbox: (sandboxId, trigger) => reconcileSandbox(ctx, sandboxId, trigger),
226
301
  };
227
302
  const unregister = workers.register(config.tenantId, worker);
228
303
  detach = async () => {
@@ -232,10 +307,11 @@ export class TenantRuntime {
232
307
  if (local)
233
308
  await local.start(workers.handlers);
234
309
  await execution.armSweep(config.tenantId);
235
- return new TenantRuntime(ctx, envelope, worker, detach, workers.graceMs);
310
+ return new TenantRuntime(ctx, envelope, worker, detach, workers.graceMs, harness, hooks.pods ? hostAuthority(ctx) : undefined);
236
311
  }
237
312
  catch (error) {
238
313
  await detach?.().catch(() => undefined);
314
+ await harness?.stop().catch(() => undefined);
239
315
  await wired?.close().catch(() => undefined);
240
316
  await store.close().catch(() => undefined);
241
317
  throw error;
@@ -256,6 +332,7 @@ export class TenantRuntime {
256
332
  inFlightDeliveries: [...this.ctx.work.deliveries.values()].reduce((n, set) => n + set.size, 0),
257
333
  pendingActions: counts.pendingActions,
258
334
  uncertainEffects: counts.uncertainEffects,
335
+ harness: harnessStatusOf(this.ctx),
259
336
  };
260
337
  }
261
338
  drain(activeWork, timeoutMs = 30_000) {
@@ -279,6 +356,9 @@ export class TenantRuntime {
279
356
  onSweep(hook) {
280
357
  return this.ctx.onSweep(hook);
281
358
  }
359
+ attachHarness(channel, peer) {
360
+ return this.ctx.harness.attach(channel, peer);
361
+ }
282
362
  async close() {
283
363
  const ctx = this.ctx;
284
364
  ctx.closing = true;
@@ -289,10 +369,18 @@ export class TenantRuntime {
289
369
  // error is rethrown at the end.
290
370
  const steps = [
291
371
  ["detach", () => this.detach()],
292
- ["mcp", () => ctx.mcp.close()],
372
+ ["mcp", () => ctx.mcp?.close()],
293
373
  ["session streams", () => endAllStreams(ctx.sessionStreams)],
294
374
  // Bounded: an advance that ignores its abort is abandoned; its lease lapses (§11.4).
295
375
  ["idle", () => waitForIdle(ctx, Math.max(0, idleBy - Date.now()))],
376
+ [
377
+ "harness",
378
+ async () => {
379
+ ctx.harness.close();
380
+ // Advances still running were abandoned at "idle": their runs are left behind too.
381
+ await this.harness?.stop(Math.max(0, idleBy - Date.now()));
382
+ },
383
+ ],
296
384
  ["sandbox", () => ctx.sandbox.close()],
297
385
  [
298
386
  "streams",
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Sandboxes as a resource (blueprint D39, F7.1; Host feature `sandboxes`): `PUT`, `GET` and
3
+ * `DELETE /v1/sandboxes/{id}`, the list by label, sessions attaching with `sandbox: { id }`, and
4
+ * the checks every turn start makes on the sandbox its session is attached to.
5
+ *
6
+ * - **No scope.** The Runtime decides nothing about who shares a sandbox. It stores the
7
+ * sandbox's id, kind, spec and labels, and the session body names the sandbox it is attached
8
+ * to (`Session.sandboxId`); deleting a session (a sessions reset) only detaches it.
9
+ * - **Access.** An application key reaches every sandbox, and so does an application key acting
10
+ * for a subject (`Nylorun-Subject`), which needs `sandboxes:write` to change one. A subject
11
+ * token reaches only the ids its `sbx` grants match, and changes them only with
12
+ * `sandboxes:write`; any other id is the 404 of a missing one.
13
+ * - **Serial turns.** One turn at a time per sandbox: a session whose sandbox another attached
14
+ * session's turn holds is refused (`409 sandbox_busy`), as the session sandbox route is
15
+ * refused while its session's turn runs. Commands within the workspace keep queueing in the
16
+ * Sandbox Manager.
17
+ * - **Kinds.** `virtual`: its workspace is the virtual backend's, keyed by the sandbox id
18
+ * (`SandboxManager.sandboxKeyOf`) and created on the first tool call. `pod` (F7.2, Host feature
19
+ * `sandbox-pods`): an agent-sandbox pod on the Tenant's cluster, created at once, whose engine
20
+ * runs the turns of the sessions attached to it. Its lifecycle (`sandbox/pods/lifecycle.ts`)
21
+ * runs in the `Sandbox` object: this module records what is wanted (`desired`, `rev`) and asks
22
+ * for a reconcile after the commit (`ctx.sandboxSignal`). Without sandbox pods kind `pod` is
23
+ * `409 sandbox_unavailable`. `POST .../stop` suspends a pod (the volume is kept; the next turn
24
+ * starts it), `POST .../reset` gives it a new pod on a new volume. An expired pod refuses
25
+ * turns (`sandbox_expired`) until a `PUT` sets a longer `lifecycle.ttl`; a lost one
26
+ * (`sandbox_lost`) until a reset. Deleting a pod sandbox keeps its row (`desired: deleted`,
27
+ * invisible here) until its Sandbox is gone.
28
+ * - **Lifecycle events** go to the sandbox's own stream in the record (`Tx.sandboxEvent`,
29
+ * `record/sandbox.ts`); the session's log records `sandbox.attached` too.
30
+ */
31
+ import { type PutSandboxRequest, type SandboxView } from "@nylorun/core/contracts";
32
+ import type { SandboxResource, Tx } from "../store/types.js";
33
+ import type { AuthScope, Session, TenantContext } from "./context.js";
34
+ /** The `sbx` grants that limit `scope`, or undefined when it reaches every sandbox. */
35
+ export declare function sandboxGrantsOf(scope: AuthScope): readonly string[] | undefined;
36
+ /** A sandbox resource, unless it is gone or being deleted. */
37
+ export declare function liveSandbox(t: Tx, id: string, options?: {
38
+ lock?: boolean;
39
+ }): Promise<SandboxResource | undefined>;
40
+ /**
41
+ * `PUT /v1/sandboxes/{id}`: creates the sandbox (within the Tenant's limit on their number), or
42
+ * finds the one with this id, so "get or create" is one call. The spec is fixed once it exists;
43
+ * labels, when sent, replace its labels. On an existing pod sandbox it also starts a stopped or
44
+ * failed pod again, and a new `lifecycle.ttl` sets a new expiry (reviving an expired one).
45
+ */
46
+ export declare function putSandbox(ctx: TenantContext, id: string, body: PutSandboxRequest, scope: AuthScope): Promise<SandboxView>;
47
+ /** `GET /v1/sandboxes/{id}`. */
48
+ export declare function getSandbox(ctx: TenantContext, id: string, scope: AuthScope): Promise<SandboxView>;
49
+ /** `GET /v1/sandboxes?label=k=v`: every sandbox with all the labels, that the caller reaches. */
50
+ export declare function listSandboxes(ctx: TenantContext, labels: Record<string, string>, scope: AuthScope): Promise<{
51
+ sandboxes: SandboxView[];
52
+ }>;
53
+ /**
54
+ * `DELETE /v1/sandboxes/{id}`: deletes the sandbox and its workspace. Refused while a session
55
+ * attached to it has an active turn. Sessions stay attached by id: their next turn is refused
56
+ * (`sandbox_unavailable`) unless a sandbox with this id exists again. A pod sandbox's row stays
57
+ * (`desired: deleted`) until its Sandbox is gone.
58
+ */
59
+ export declare function deleteSandbox(ctx: TenantContext, id: string, scope: AuthScope): Promise<{
60
+ id: string;
61
+ deleted: boolean;
62
+ }>;
63
+ /**
64
+ * `POST /v1/sandboxes/{id}/stop`: suspends a pod sandbox. Its volume is kept, and the next turn
65
+ * of a session attached to it (or a `PUT`) starts it again. Refused during a turn.
66
+ */
67
+ export declare function stopSandbox(ctx: TenantContext, id: string, scope: AuthScope): Promise<SandboxView>;
68
+ /**
69
+ * `POST /v1/sandboxes/{id}/reset`: a new pod on a new, empty volume (the next volume
70
+ * generation), with a new join token; the old Sandbox and volume are deleted. Leaves `lost` and
71
+ * `failed`; an expired sandbox stays expired. Refused during a turn, and while an earlier
72
+ * reset's Sandbox is still being deleted.
73
+ */
74
+ export declare function resetSandbox(ctx: TenantContext, id: string, scope: AuthScope): Promise<SandboxView>;
75
+ /** `GET /v1/sandboxes/{id}/events`: the sandbox's lifecycle stream. */
76
+ export declare function sandboxEventsOf(ctx: TenantContext, id: string, scope: AuthScope, fromSeq: number | undefined): Promise<{
77
+ events: {
78
+ schema: "nylorun.sandbox-event/1";
79
+ eventId: string;
80
+ tenantId: string;
81
+ sandboxId: string;
82
+ seq: number;
83
+ time: string;
84
+ type: "sandbox.attached" | "sandbox.created" | "sandbox.deleted" | "sandbox.detached" | "sandbox.expired" | "sandbox.failed" | "sandbox.lost" | "sandbox.relaunched" | "sandbox.reset" | "sandbox.running" | "sandbox.suspended";
85
+ payload: Record<string, unknown>;
86
+ }[];
87
+ }>;
88
+ /**
89
+ * Attaches a new session to sandbox `id` (`PutSessionRequest.sandbox = { id }`): the sandbox
90
+ * must exist and be reachable with `grants`. Returns its spec, which the session pins. Runs in
91
+ * the session's transaction, after its session row lock.
92
+ */
93
+ export declare function attachSandbox(t: Tx, id: string, grants: readonly string[] | undefined): Promise<SandboxResource>;
94
+ /** Records the attachment on the session's log and on the sandbox's stream. */
95
+ export declare function recordAttachment(t: Tx, sessionId: string, sandboxId: string): Promise<void>;
96
+ /**
97
+ * Checks a turn start on a session attached to a sandbox, in the command's transaction after
98
+ * the session row lock: the sandbox still exists, a subject token's `sbx` grants reach it, and
99
+ * no other session attached to it has a turn running. Holds the sandbox row's lock until the
100
+ * transaction ends, so two turn starts on one sandbox are decided one after the other.
101
+ *
102
+ * A pod sandbox must also be usable: not lost (`sandbox_lost`) or expired (`sandbox_expired`),
103
+ * on a Runtime with sandbox pods (`sandbox_unavailable`). A stopped or failed pod is asked to
104
+ * run again, after the commit.
105
+ */
106
+ export declare function checkSandboxTurn(t: Tx, session: Session, scope: AuthScope, ctx?: TenantContext): Promise<void>;
107
+ /** A sessions reset detaches every session from its sandbox: each sandbox's stream says so. */
108
+ export declare function detachAllSessions(t: Tx): Promise<void>;
109
+ /**
110
+ * Marks the end of a turn's segment on pod sandbox `id`: idle counts from now, and the idle
111
+ * timer is armed (D34). Best effort.
112
+ */
113
+ export declare function touchPodSandbox(ctx: TenantContext, id: string): Promise<void>;
114
+ /**
115
+ * Re-arms the idle timer of every running pod sandbox after the Tenant's settings change, so a
116
+ * shorter `limits.idle` applies to pods already idle (D36: the settings are read whenever a
117
+ * timer is set). Best effort.
118
+ */
119
+ export declare function rearmPodTimers(ctx: TenantContext): Promise<void>;