@nylorun/runtime 0.22.0-beta → 0.24.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 (273) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +95 -30
  3. package/dist/adapters/execution/restate.d.ts +15 -3
  4. package/dist/adapters/execution/restate.js +78 -84
  5. package/dist/adapters/replication/pgoutput.d.ts +2 -0
  6. package/dist/adapters/replication/pgoutput.js +6 -3
  7. package/dist/adapters/sandbox/local.d.ts +9 -1
  8. package/dist/adapters/sandbox/local.js +16 -4
  9. package/dist/adapters/sandbox/virtual.js +4 -1
  10. package/dist/adapters/streams/s2.js +1 -13
  11. package/dist/api/a2a/endpoint.js +5 -1
  12. package/dist/api/a2a/routes.d.ts +2 -0
  13. package/dist/api/a2a/routes.js +2 -2
  14. package/dist/api/a2a/tasks.js +2 -1
  15. package/dist/api/ag-ui/endpoint.js +4 -2
  16. package/dist/api/ag-ui/history.js +4 -1
  17. package/dist/api/ag-ui/routes.d.ts +4 -1
  18. package/dist/api/ag-ui/routes.js +11 -10
  19. package/dist/api/ag-ui/translate.js +3 -2
  20. package/dist/api/components.d.ts +4953 -1204
  21. package/dist/api/components.js +25 -16
  22. package/dist/api/gate/keys.js +7 -1
  23. package/dist/api/gate/routes.d.ts +6 -0
  24. package/dist/api/gate/routes.js +39 -3
  25. package/dist/api/http/app.js +4 -4
  26. package/dist/api/http/define.d.ts +6 -0
  27. package/dist/api/http/define.js +13 -1
  28. package/dist/api/http/respond.js +1 -1
  29. package/dist/api/http/routes/artifacts.js +35 -8
  30. package/dist/api/http/routes/coverage.d.ts +9 -0
  31. package/dist/api/http/routes/coverage.js +34 -0
  32. package/dist/api/http/routes/environments.d.ts +11 -0
  33. package/dist/api/http/routes/environments.js +283 -0
  34. package/dist/api/http/routes/mcp.d.ts +1 -1
  35. package/dist/api/http/routes/mcp.js +2 -2
  36. package/dist/api/http/routes/me.js +1 -1
  37. package/dist/api/http/routes/reads.d.ts +45 -9
  38. package/dist/api/http/routes/reads.js +54 -5
  39. package/dist/api/http/routes/sessions.js +11 -6
  40. package/dist/api/http/routes/tenant.js +32 -10
  41. package/dist/api/openapi.js +31 -26
  42. package/dist/artifacts/disk.d.ts +59 -0
  43. package/dist/artifacts/disk.js +563 -0
  44. package/dist/artifacts/export.d.ts +1 -1
  45. package/dist/artifacts/export.js +2 -3
  46. package/dist/artifacts/folders.d.ts +6 -3
  47. package/dist/artifacts/folders.js +17 -12
  48. package/dist/artifacts/service.d.ts +2 -1
  49. package/dist/artifacts/service.js +6 -1
  50. package/dist/artifacts/workspace.d.ts +1 -1
  51. package/dist/artifacts/workspace.js +1 -1
  52. package/dist/core/flow-host.d.ts +3 -22
  53. package/dist/core/flow-host.js +11 -37
  54. package/dist/core/provider.d.ts +3 -2
  55. package/dist/core/provider.js +5 -37
  56. package/dist/core/sandbox-routes.d.ts +2 -21
  57. package/dist/core/sandbox-routes.js +10 -43
  58. package/dist/execution/types.d.ts +11 -9
  59. package/dist/execution/types.js +5 -7
  60. package/dist/gates/contract.d.ts +3 -24
  61. package/dist/gates/contract.js +1 -36
  62. package/dist/gates/egress-credentials.d.ts +9 -0
  63. package/dist/gates/egress-credentials.js +11 -4
  64. package/dist/gates/egress.d.ts +28 -1
  65. package/dist/gates/egress.js +115 -10
  66. package/dist/gates/fixture-model.d.ts +28 -0
  67. package/dist/gates/fixture-model.js +92 -0
  68. package/dist/gates/handler.js +4 -4
  69. package/dist/gates/http-tool.d.ts +6 -2
  70. package/dist/gates/http-tool.js +3 -5
  71. package/dist/gates/in-process.d.ts +5 -0
  72. package/dist/gates/in-process.js +12 -1
  73. package/dist/gates/journal.d.ts +30 -0
  74. package/dist/gates/journal.js +52 -0
  75. package/dist/gates/mcp-handler.d.ts +4 -0
  76. package/dist/gates/mcp-handler.js +5 -1
  77. package/dist/gates/meter.d.ts +10 -2
  78. package/dist/gates/meter.js +20 -5
  79. package/dist/gates/policy.d.ts +49 -0
  80. package/dist/gates/policy.js +134 -0
  81. package/dist/gates/tenant-vaults.d.ts +7 -7
  82. package/dist/gates/tenant-vaults.js +5 -5
  83. package/dist/gates/tool-calls.d.ts +10 -5
  84. package/dist/gates/tool-calls.js +28 -10
  85. package/dist/gates/tool-client.d.ts +8 -0
  86. package/dist/gates/tool-client.js +27 -11
  87. package/dist/gates/tool-contract.d.ts +10 -68
  88. package/dist/gates/tool-contract.js +1 -85
  89. package/dist/gates/tool-gate.d.ts +1 -1
  90. package/dist/harness/calls.d.ts +12 -5
  91. package/dist/harness/calls.js +21 -12
  92. package/dist/harness/executors.js +12 -9
  93. package/dist/harness/main.js +18 -5
  94. package/dist/harness/pod.d.ts +34 -0
  95. package/dist/harness/pod.js +41 -0
  96. package/dist/harness/service.d.ts +0 -1
  97. package/dist/harness/service.js +13 -5
  98. package/dist/harness/setup.d.ts +27 -0
  99. package/dist/harness/setup.js +150 -0
  100. package/dist/harness/ws-client.js +3 -2
  101. package/dist/harness/ws-port.d.ts +1 -5
  102. package/dist/harness/ws-port.js +1 -5
  103. package/dist/harness-api/in-process.js +3 -3
  104. package/dist/harness-api/record.d.ts +15 -1
  105. package/dist/harness-api/record.js +174 -44
  106. package/dist/harness-api/redact.d.ts +3 -0
  107. package/dist/harness-api/redact.js +45 -0
  108. package/dist/harness-api/server.d.ts +8 -0
  109. package/dist/harness-api/server.js +93 -13
  110. package/dist/harness-api/start.d.ts +10 -3
  111. package/dist/harness-api/start.js +40 -10
  112. package/dist/harness-api/tool-journal.d.ts +96 -0
  113. package/dist/harness-api/tool-journal.js +102 -0
  114. package/dist/harness-api/tool-status.d.ts +13 -0
  115. package/dist/harness-api/tool-status.js +26 -0
  116. package/dist/harness-api/workspace.d.ts +6 -3
  117. package/dist/harness-api/workspace.js +27 -11
  118. package/dist/harness-api/ws-server.d.ts +11 -3
  119. package/dist/harness-api/ws-server.js +94 -15
  120. package/dist/host/execution.d.ts +8 -6
  121. package/dist/host/gates.d.ts +2 -1
  122. package/dist/host/gates.js +10 -2
  123. package/dist/host/main.js +64 -21
  124. package/dist/host/operate.js +67 -5
  125. package/dist/host/stack-config.d.ts +70 -0
  126. package/dist/host/stack-config.js +67 -1
  127. package/dist/infra/execution.d.ts +18 -1
  128. package/dist/infra/execution.js +28 -4
  129. package/dist/infra/index.d.ts +1 -1
  130. package/dist/keys/client.js +4 -1
  131. package/dist/keys/keys.d.ts +5 -5
  132. package/dist/keys/keys.js +5 -7
  133. package/dist/management-openapi.json +999 -2211
  134. package/dist/mcp/connect.d.ts +25 -20
  135. package/dist/mcp/connect.js +29 -30
  136. package/dist/mcp/pool.d.ts +2 -2
  137. package/dist/mcp/pool.js +1 -1
  138. package/dist/mcp/preview.d.ts +3 -3
  139. package/dist/mcp/preview.js +10 -20
  140. package/dist/mcp/snapshot.d.ts +5 -11
  141. package/dist/mcp/snapshot.js +6 -7
  142. package/dist/model/pi-model.js +8 -0
  143. package/dist/openapi.json +6955 -2526
  144. package/dist/reads/types.d.ts +16 -3
  145. package/dist/record/append.d.ts +3 -1
  146. package/dist/record/append.js +1 -0
  147. package/dist/record/envelope.d.ts +5 -1
  148. package/dist/record/envelope.js +7 -3
  149. package/dist/record/environment.d.ts +25 -0
  150. package/dist/record/environment.js +44 -0
  151. package/dist/record/index.d.ts +1 -1
  152. package/dist/record/index.js +1 -1
  153. package/dist/retry.d.ts +21 -0
  154. package/dist/retry.js +34 -0
  155. package/dist/sandbox/disk-fs.d.ts +95 -0
  156. package/dist/sandbox/disk-fs.js +206 -0
  157. package/dist/sandbox/join.d.ts +17 -18
  158. package/dist/sandbox/join.js +46 -20
  159. package/dist/sandbox/manager.d.ts +16 -8
  160. package/dist/sandbox/manager.js +33 -17
  161. package/dist/sandbox/placement.d.ts +13 -12
  162. package/dist/sandbox/placement.js +29 -12
  163. package/dist/sandbox/pods/client.d.ts +6 -1
  164. package/dist/sandbox/pods/lifecycle.d.ts +28 -27
  165. package/dist/sandbox/pods/lifecycle.js +14 -26
  166. package/dist/sandbox/pods/reconcile.js +21 -15
  167. package/dist/sandbox/pods/spec.d.ts +15 -13
  168. package/dist/sandbox/pods/spec.js +16 -20
  169. package/dist/sandbox/records.d.ts +2 -2
  170. package/dist/sandbox/records.js +3 -3
  171. package/dist/sandbox/resolve.d.ts +35 -27
  172. package/dist/sandbox/resolve.js +66 -41
  173. package/dist/sandbox/session-sandbox.js +1 -1
  174. package/dist/sandbox/setup.d.ts +61 -0
  175. package/dist/sandbox/setup.js +123 -0
  176. package/dist/sandbox/share.d.ts +11 -21
  177. package/dist/sandbox/share.js +12 -41
  178. package/dist/sandbox/tenant-config.d.ts +12 -14
  179. package/dist/sandbox/tenant-config.js +12 -17
  180. package/dist/sandbox/types.d.ts +6 -0
  181. package/dist/store/postgres/control.js +8 -1
  182. package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
  183. package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
  184. package/dist/store/postgres/drizzle/0020_environments.sql +63 -0
  185. package/dist/store/postgres/drizzle/0021_disk_versions.sql +7 -0
  186. package/dist/store/postgres/drizzle/0022_model_usage_timing.sql +5 -0
  187. package/dist/store/postgres/drizzle/0023_artifact_pages.sql +1 -0
  188. package/dist/store/postgres/drizzle/meta/_journal.json +42 -0
  189. package/dist/store/postgres/reads.js +117 -17
  190. package/dist/store/postgres/record-writer.d.ts +4 -4
  191. package/dist/store/postgres/record-writer.js +9 -9
  192. package/dist/store/postgres/record.d.ts +7 -0
  193. package/dist/store/postgres/record.js +23 -2
  194. package/dist/store/postgres/schema.d.ts +1213 -779
  195. package/dist/store/postgres/schema.js +136 -76
  196. package/dist/store/postgres/store.js +283 -117
  197. package/dist/store/types.d.ts +206 -88
  198. package/dist/streams/relay/core.js +1 -13
  199. package/dist/tenant/advance.d.ts +7 -6
  200. package/dist/tenant/advance.js +121 -49
  201. package/dist/tenant/artifact-tool.js +3 -4
  202. package/dist/tenant/commands.d.ts +15 -2
  203. package/dist/tenant/commands.js +94 -14
  204. package/dist/tenant/context.d.ts +67 -33
  205. package/dist/tenant/context.js +4 -15
  206. package/dist/tenant/control.d.ts +8 -1
  207. package/dist/tenant/control.js +2 -0
  208. package/dist/tenant/effects.d.ts +1 -1
  209. package/dist/tenant/effects.js +37 -20
  210. package/dist/tenant/environments.d.ts +167 -0
  211. package/dist/tenant/environments.js +823 -0
  212. package/dist/tenant/harness-tokens.d.ts +21 -0
  213. package/dist/tenant/harness-tokens.js +61 -0
  214. package/dist/tenant/host-token.d.ts +1 -1
  215. package/dist/tenant/host-token.js +2 -4
  216. package/dist/tenant/identity-file.d.ts +4 -4
  217. package/dist/tenant/identity-file.js +17 -8
  218. package/dist/tenant/issuers.js +3 -3
  219. package/dist/tenant/principals.js +1 -1
  220. package/dist/tenant/reset.js +3 -3
  221. package/dist/tenant/runtime.d.ts +6 -1
  222. package/dist/tenant/runtime.js +30 -4
  223. package/dist/tenant/scheduler.d.ts +7 -0
  224. package/dist/tenant/scheduler.js +38 -7
  225. package/dist/tenant/session-streams.d.ts +0 -1
  226. package/dist/tenant/session-streams.js +1 -13
  227. package/dist/tenant/session.d.ts +3 -3
  228. package/dist/tenant/session.js +0 -14
  229. package/dist/tenant/sessions.d.ts +13 -13
  230. package/dist/tenant/sessions.js +61 -138
  231. package/dist/tenant/signing-keys.d.ts +5 -1
  232. package/dist/tenant/status.js +3 -3
  233. package/dist/tenant/streams.js +14 -29
  234. package/dist/tenant/sweep.d.ts +39 -12
  235. package/dist/tenant/sweep.js +168 -63
  236. package/dist/tenant/transcript.d.ts +210 -20
  237. package/dist/tenant/transcript.js +62 -11
  238. package/dist/tenant/turn-timing.d.ts +43 -0
  239. package/dist/tenant/turn-timing.js +39 -0
  240. package/dist/tenant/types.d.ts +2 -0
  241. package/dist/traces/ids.d.ts +42 -0
  242. package/dist/traces/ids.js +85 -0
  243. package/dist/traces/key.d.ts +6 -0
  244. package/dist/traces/key.js +37 -0
  245. package/dist/traces/map.d.ts +113 -0
  246. package/dist/traces/map.js +317 -0
  247. package/dist/traces/options.d.ts +7 -0
  248. package/dist/traces/options.js +1 -0
  249. package/dist/traces/otlp.d.ts +42 -0
  250. package/dist/traces/otlp.js +84 -0
  251. package/dist/traces/projector.d.ts +58 -0
  252. package/dist/traces/projector.js +200 -0
  253. package/dist/traces/read.d.ts +23 -0
  254. package/dist/traces/read.js +16 -0
  255. package/dist/vault/coverage.d.ts +26 -0
  256. package/dist/vault/coverage.js +64 -0
  257. package/dist/vault/error.d.ts +11 -1
  258. package/dist/vault/error.js +5 -1
  259. package/dist/vault/service.d.ts +47 -48
  260. package/dist/vault/service.js +321 -382
  261. package/dist/vault/sources.d.ts +5 -16
  262. package/dist/vault/sources.js +6 -8
  263. package/dist/version.d.ts +1 -1
  264. package/dist/version.js +2 -2
  265. package/package.json +14 -13
  266. package/dist/api/http/routes/sandboxes.d.ts +0 -9
  267. package/dist/api/http/routes/sandboxes.js +0 -153
  268. package/dist/api/http/routes/vaults.d.ts +0 -10
  269. package/dist/api/http/routes/vaults.js +0 -139
  270. package/dist/record/sandbox.d.ts +0 -25
  271. package/dist/record/sandbox.js +0 -43
  272. package/dist/tenant/sandboxes.d.ts +0 -122
  273. package/dist/tenant/sandboxes.js +0 -562
@@ -12,10 +12,14 @@
12
12
  * Nothing here knows a tool: the header name and its format are the credential's. A release the
13
13
  * vault refuses (two secrets for one host, an unreadable value) answers 502 and is logged, never
14
14
  * as an event; a value never is. Upgrades (WebSocket) and HTTP/2 are not served on this path.
15
+ * Each connection keeps a tally for its `egress.connection` event (R7.3): how many requests it
16
+ * read and which credentials it set, by id.
15
17
  */
16
18
  import { createServer } from "node:http";
17
19
  import { Agent, request } from "node:https";
18
20
  import { createSecureContext, TLSSocket } from "node:tls";
21
+ /** The answer to a CONNECT the gate serves; a tunnel's byte counts leave it out. */
22
+ export const CONNECT_ESTABLISHED = "HTTP/1.1 200 Connection Established\r\n\r\n";
19
23
  /** Headers that describe one hop and are never forwarded. */
20
24
  const HOP_BY_HOP = new Set([
21
25
  "connection",
@@ -74,6 +78,7 @@ export function credentialTerminator(options) {
74
78
  return;
75
79
  }
76
80
  const { sandboxId, host } = at;
81
+ at.tally.requests += 1;
77
82
  const release = await credentials.release(sandboxId, host);
78
83
  if (release.status === "refused") {
79
84
  logger.warn("egress_credential_refused", { status: 502, reason: release.reason, sandboxId, host });
@@ -82,8 +87,10 @@ export function credentialTerminator(options) {
82
87
  }
83
88
  const header = release.status === "released" ? release.header.toLowerCase() : undefined;
84
89
  const headers = forwardable(req.rawHeaders, header);
85
- if (release.status === "released")
90
+ if (release.status === "released") {
86
91
  headers[header] = [release.value];
92
+ at.tally.credentialIds.add(release.credentialId);
93
+ }
87
94
  const upstream = request({
88
95
  host,
89
96
  port: credentials.upstreamPort ?? 443,
@@ -111,7 +118,7 @@ export function credentialTerminator(options) {
111
118
  }
112
119
  return {
113
120
  async terminate(client, target) {
114
- const { sandboxId, host, addresses } = target;
121
+ const { sandboxId, host, addresses, tally } = target;
115
122
  const leaf = await credentials.leaf(host);
116
123
  if (client.destroyed)
117
124
  return;
@@ -128,13 +135,13 @@ export function credentialTerminator(options) {
128
135
  lookup,
129
136
  ...(credentials.upstreamCa ? { ca: credentials.upstreamCa } : {}),
130
137
  });
131
- client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
138
+ client.write(CONNECT_ESTABLISHED);
132
139
  const tls = new TLSSocket(client, {
133
140
  isServer: true,
134
141
  secureContext: createSecureContext({ key: leaf.key, cert: leaf.cert }),
135
142
  ALPNProtocols: ["http/1.1"],
136
143
  });
137
- terminated.set(tls, { sandboxId, host, agent });
144
+ terminated.set(tls, { sandboxId, host, agent, tally });
138
145
  tls.on("error", () => tls.destroy());
139
146
  tls.on("close", () => {
140
147
  agent.destroy();
@@ -1,5 +1,6 @@
1
1
  import { type Server } from "node:http";
2
2
  import { type Socket } from "node:net";
3
+ import type { EventPayload, EventWriter } from "@nylorun/core/contracts";
3
4
  import type { EgressTokenVerdict } from "../sandbox/egress-token.js";
4
5
  import type { SessionStore } from "../store/types.js";
5
6
  import type { Logger } from "../tenant/types.js";
@@ -15,6 +16,22 @@ export interface EgressSandboxes {
15
16
  readonly allow: readonly string[];
16
17
  } | undefined>;
17
18
  }
19
+ /** The session and turn holding an Environment: turns are serial per Environment, so one at most. */
20
+ export interface EgressHolder {
21
+ readonly sessionId: string;
22
+ readonly turnId: string;
23
+ }
24
+ /** One connection's record (R7.3): `egress.connection`'s payload, `environment.egress`'s too. */
25
+ export type EgressConnection = EventPayload<"egress.connection">;
26
+ /** Where egress-gate records connections (R7.3). */
27
+ export interface EgressRecord {
28
+ /** The session whose turn holds the Environment now; `undefined` between turns. */
29
+ holder(environmentId: string): Promise<EgressHolder | undefined>;
30
+ /** Writes `connection` to `holder`'s session log, or to the Environment's stream without one. */
31
+ write(holder: EgressHolder | undefined, connection: EgressConnection): Promise<void>;
32
+ }
33
+ /** egress-gate as the writer of its events (R4.9): the gate saw the connection itself. */
34
+ export declare const EGRESS_WRITER: EventWriter;
18
35
  /** Ports a tunnel may open. */
19
36
  export declare const EGRESS_PORTS: readonly number[];
20
37
  /** Tunnels one sandbox may hold open at once. */
@@ -49,6 +66,8 @@ export interface StartEgressGateOptions {
49
66
  readonly dial?: (address: string, port: number) => Socket;
50
67
  /** The vault and keys, for hosts an `environment_secret` is bound to (R2c). None: tunnels only. */
51
68
  readonly credentials?: EgressCredentials;
69
+ /** Where connections are recorded (R7.3). None: they are only logged. */
70
+ readonly record?: EgressRecord;
52
71
  }
53
72
  export interface EgressGate {
54
73
  /** Where the gate listens, e.g. `http://0.0.0.0:4200`. */
@@ -70,6 +89,14 @@ export declare function startEgressGate(options: StartEgressGateOptions): Promis
70
89
  /**
71
90
  * `EgressSandboxes` over the Tenant's `sandbox_resources` (one indexed read): a pod sandbox's
72
91
  * host epoch, the pod that joined at it and its spec's allowlist; nothing for a virtual
73
- * sandbox, or one deleted, lost or expired, as `staleHost` judges a host token.
92
+ * Environment, or one deleted or lost, as `staleHost` judges a host token.
74
93
  */
75
94
  export declare function storeEgressSandboxes(store: SessionStore): EgressSandboxes;
95
+ /**
96
+ * `EgressRecord` over the Tenant's record (R7.3). The holder is the session on the Environment
97
+ * whose body has an `activeTurnId` (turns are serial per Environment). With a holder, the write
98
+ * is `egress.connection` on its session log, at that turn, under the session row's lock; without
99
+ * one, `environment.egress` on the Environment's stream, under its row's lock. A deleted
100
+ * Environment gets nothing.
101
+ */
102
+ export declare function storeEgressRecord(store: SessionStore): EgressRecord;
@@ -21,8 +21,17 @@
21
21
  * `environment_secret` of a live session on the sandbox is bound to. There the gate terminates
22
22
  * TLS and sets the credential's header on every request (`egress-credentials.ts`), still sending
23
23
  * only to the checked address. Every other host gets the tunnel. Any other request
24
- * (an absolute-form plain HTTP request included) is answered 405. Refusals are logged, never as
25
- * events; the token never is. Tunnels close after `idleMs` without traffic in either direction.
24
+ * (an absolute-form plain HTTP request included) is answered 405. Refusals are logged; the token
25
+ * never is. Tunnels close after `idleMs` without traffic in either direction.
26
+ *
27
+ * Every connection to a named host on a live token is recorded once (R7.3, Q6, Q7), when it
28
+ * closes or at once when the gate refuses it: `egress.connection` on the session log of the turn
29
+ * that held the Environment when it opened (read as the name resolves), else `environment.egress`
30
+ * on the Environment's own stream. Host, port, outcome, bytes, duration and, for a credential
31
+ * host, the request count and credential ids; never a header value, a path or a body. A refusal
32
+ * before that (no token, a stale one, no `host:port`) stays a log line, and a client that hangs up
33
+ * before the gate answers leaves nothing. A write never holds the tunnel up, and a failed one is
34
+ * logged (`egress_record_failed`).
26
35
  */
27
36
  import { lookup as dnsLookup } from "node:dns/promises";
28
37
  import { createServer } from "node:http";
@@ -30,7 +39,9 @@ import { BlockList, connect, isIP } from "node:net";
30
39
  import { isSandboxHostPattern } from "@nylorun/core/define";
31
40
  import { isPrivateAddress } from "../tenant/outbound.js";
32
41
  import { bindListener } from "../host/http.js";
33
- import { credentialTerminator } from "./egress-credentials.js";
42
+ import { CONNECT_ESTABLISHED, credentialTerminator, } from "./egress-credentials.js";
43
+ /** egress-gate as the writer of its events (R4.9): the gate saw the connection itself. */
44
+ export const EGRESS_WRITER = { source: { kind: "egress-gate", id: "egress" }, evidence: "observed" };
34
45
  /** Ports a tunnel may open. */
35
46
  export const EGRESS_PORTS = [443, 80];
36
47
  /** Tunnels one sandbox may hold open at once. */
@@ -134,6 +145,49 @@ function answer(socket, status, message, headers = []) {
134
145
  body,
135
146
  ].join("\r\n"));
136
147
  }
148
+ function connectionRecord(sink, logger, client, at) {
149
+ let holding;
150
+ let written = false;
151
+ const holder = (to) => (holding ??= to.holder(at.environmentId));
152
+ const write = (fields) => {
153
+ if (written || !sink)
154
+ return;
155
+ written = true;
156
+ const socket = client;
157
+ const allowed = fields.outcome === "allowed";
158
+ const connection = {
159
+ environmentId: at.environmentId,
160
+ host: at.host,
161
+ port: at.port,
162
+ ...fields,
163
+ mode: record.credentialBound ? "credential" : "tunnel",
164
+ credentialBound: record.credentialBound,
165
+ // Up is what the sandbox sent after its CONNECT, down what it got after the gate's 200.
166
+ bytesUp: allowed ? Math.max(0, (socket.bytesRead ?? 0) - at.upFrom) : 0,
167
+ bytesDown: allowed ? Math.max(0, (socket.bytesWritten ?? 0) - CONNECT_ESTABLISHED.length) : 0,
168
+ durationMs: Math.max(0, Date.now() - at.startedAt.getTime()),
169
+ startedAt: at.startedAt.toISOString(),
170
+ };
171
+ holder(sink)
172
+ .then((held) => sink.write(held, connection))
173
+ .catch((error) => logger.error("egress_record_failed", {
174
+ sandboxId: at.environmentId,
175
+ host: at.host,
176
+ error: error instanceof Error ? error.message : String(error),
177
+ }));
178
+ };
179
+ const record = {
180
+ credentialBound: false,
181
+ // A failed read is logged when the write needs it.
182
+ holder: () => void (sink && holder(sink).catch(() => undefined)),
183
+ refused: (status, reason) => write({ outcome: "refused", reason, status }),
184
+ closed: (tally) => write({
185
+ outcome: "allowed",
186
+ ...(tally ? { requests: tally.requests, credentialIds: [...tally.credentialIds] } : {}),
187
+ }),
188
+ };
189
+ return record;
190
+ }
137
191
  export async function startEgressGate(options) {
138
192
  const { logger, sandboxes } = options;
139
193
  const idleMs = options.idleMs ?? EGRESS_IDLE_MS;
@@ -160,11 +214,17 @@ export async function startEgressGate(options) {
160
214
  : undefined;
161
215
  const open = new Map();
162
216
  const tunnels = new Set();
217
+ /** Each connection's record, once its Environment and target are known. */
218
+ const records = new WeakMap();
163
219
  async function onConnect(req, client, head) {
220
+ const startedAt = new Date();
221
+ // What the sandbox sent before the tunnel: its CONNECT, less the bytes after it (`head`).
222
+ const upFrom = (client.bytesRead ?? 0) - head.length;
164
223
  const target = parseTarget(req.url);
165
224
  const refuse = (status, reason, fields = {}, message) => {
166
225
  logger.warn("egress_refused", { status, reason, ...(target ? { host: target.host, port: target.port } : {}), ...fields });
167
226
  answer(client, status, message ?? reason, status === 407 ? ['proxy-authenticate: Basic realm="nylorun-egress"'] : []);
227
+ records.get(client)?.refused(status, reason);
168
228
  };
169
229
  const raw = proxyToken(req.headers["proxy-authorization"]);
170
230
  if (!raw)
@@ -183,12 +243,22 @@ export async function startEgressGate(options) {
183
243
  if (!target)
184
244
  return refuse(403, "target_invalid", { sandboxId }, "CONNECT needs host:port");
185
245
  const { host, port } = target;
246
+ const record = connectionRecord(options.record, logger, client, {
247
+ environmentId: sandboxId,
248
+ host,
249
+ port,
250
+ startedAt,
251
+ upFrom,
252
+ });
253
+ records.set(client, record);
186
254
  if (!EGRESS_PORTS.includes(port))
187
255
  return refuse(403, "port_refused", { sandboxId }, `port ${port} is not allowed; tunnels open to 443 or 80 only`);
188
256
  if (isIP(host.replace(/^\[|\]$/g, "")) !== 0 || !isSandboxHostPattern(host) || host.startsWith("*."))
189
257
  return refuse(403, "not_a_host_name", { sandboxId }, `${host} is not a host name; IP literals are refused`);
190
258
  if (!egressAllows(host, sandbox.allow))
191
259
  return refuse(403, "host_not_allowed", { sandboxId }, `${host} is not in this sandbox's network.allow`);
260
+ // Who holds the Environment as the connection opens, read while the name resolves.
261
+ record.holder();
192
262
  let mine = open.get(sandboxId);
193
263
  if (!mine)
194
264
  open.set(sandboxId, (mine = new Set()));
@@ -217,10 +287,13 @@ export async function startEgressGate(options) {
217
287
  if (client.destroyed)
218
288
  return;
219
289
  if (terminator && port === 443 && (await options.credentials.bound(sandboxId, host))) {
290
+ record.credentialBound = true;
220
291
  // A client that spoke before the 200 is not one this path serves.
221
292
  if (head.length > 0)
222
293
  return refuse(403, "early_data", { sandboxId }, "wait for the CONNECT answer before sending");
223
- return terminator.terminate(client, { sandboxId, host, addresses: allowed });
294
+ const tally = { requests: 0, credentialIds: new Set() };
295
+ client.once("close", () => record.closed(tally));
296
+ return terminator.terminate(client, { sandboxId, host, addresses: allowed, tally });
224
297
  }
225
298
  const upstream = await dialFirst(allowed.map((entry) => entry.address), port);
226
299
  if (!upstream)
@@ -237,8 +310,11 @@ export async function startEgressGate(options) {
237
310
  upstream.on("timeout", close);
238
311
  upstream.on("error", close);
239
312
  upstream.on("close", () => client.destroy());
240
- client.on("close", () => upstream.destroy());
241
- client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
313
+ client.on("close", () => {
314
+ upstream.destroy();
315
+ record.closed();
316
+ });
317
+ client.write(CONNECT_ESTABLISHED);
242
318
  if (head.length > 0)
243
319
  upstream.write(head);
244
320
  upstream.pipe(client);
@@ -284,6 +360,7 @@ export async function startEgressGate(options) {
284
360
  onConnect(req, client, head).catch((error) => {
285
361
  logger.error("egress_failed", { error: error instanceof Error ? error.message : String(error) });
286
362
  answer(client, 502, "the tunnel failed");
363
+ records.get(client)?.refused(502, "egress_failed");
287
364
  });
288
365
  });
289
366
  server.on("clientError", (_error, socket) => {
@@ -311,22 +388,50 @@ export async function startEgressGate(options) {
311
388
  /**
312
389
  * `EgressSandboxes` over the Tenant's `sandbox_resources` (one indexed read): a pod sandbox's
313
390
  * host epoch, the pod that joined at it and its spec's allowlist; nothing for a virtual
314
- * sandbox, or one deleted, lost or expired, as `staleHost` judges a host token.
391
+ * Environment, or one deleted or lost, as `staleHost` judges a host token.
315
392
  */
316
393
  export function storeEgressSandboxes(store) {
317
394
  return {
318
395
  async live(sandboxId) {
319
- const sandbox = await store.tx((t) => t.sandboxResource(sandboxId));
396
+ const sandbox = await store.tx((t) => t.environment(sandboxId));
320
397
  const pod = sandbox?.pod;
321
398
  if (!sandbox || sandbox.kind !== "pod" || !pod)
322
399
  return undefined;
323
- if (pod.desired === "deleted" || pod.observed === "lost" || pod.observed === "expired")
400
+ if (pod.desired === "deleted" || pod.observed === "lost")
324
401
  return undefined;
325
402
  return {
326
403
  epoch: pod.hostEpoch,
327
404
  ...(pod.podUid ? { podUid: pod.podUid } : {}),
328
- allow: sandbox.spec.network?.allow ?? [],
405
+ // The applied allowlist: a recipe change writes it at once (Q8).
406
+ allow: sandbox.applied.network?.allowedHosts ?? [],
329
407
  };
330
408
  },
331
409
  };
332
410
  }
411
+ /**
412
+ * `EgressRecord` over the Tenant's record (R7.3). The holder is the session on the Environment
413
+ * whose body has an `activeTurnId` (turns are serial per Environment). With a holder, the write
414
+ * is `egress.connection` on its session log, at that turn, under the session row's lock; without
415
+ * one, `environment.egress` on the Environment's stream, under its row's lock. A deleted
416
+ * Environment gets nothing.
417
+ */
418
+ export function storeEgressRecord(store) {
419
+ return {
420
+ async holder(environmentId) {
421
+ const sessions = await store.tx((t) => t.sessionsOnEnvironment(environmentId));
422
+ const holding = sessions.find((session) => typeof session.activeTurnId === "string");
423
+ return holding ? { sessionId: holding.id, turnId: holding.activeTurnId } : undefined;
424
+ },
425
+ async write(holder, connection) {
426
+ await store.tx(async (t) => {
427
+ if (holder) {
428
+ await t.event(holder.sessionId, holder.turnId, "egress.connection", connection, EGRESS_WRITER);
429
+ return;
430
+ }
431
+ if (!(await t.environment(connection.environmentId, { lock: true })))
432
+ return;
433
+ await t.environmentEvent(connection.environmentId, "environment.egress", connection);
434
+ });
435
+ },
436
+ };
437
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The fixture model (R4.3): a deterministic, scripted model the Model Gate answers with when the
3
+ * Tenant's setting `model.fixture` is on (`PUT /v1/tenant/config/seed {fixtureModel: true}`).
4
+ * The gate answers it itself, metered like any model (a `model_usage` row per call, provider
5
+ * `fixture`, tokens counted from the text, cost 0), so every harness that calls the gate is seen
6
+ * the same way: ours, and one written from the published contract.
7
+ *
8
+ * What it answers is read from the call: the last user message and the offered tools.
9
+ * - `fixture:text`: one text answer, `fixture ok`.
10
+ * - `fixture:tool <name> <json>`: one call of the offered tool `<name>` with `<json>` as its
11
+ * arguments; once the tool's result is in the prompt, a text answer that quotes it.
12
+ * - `fixture:unoffered <name> <json>`: the same call of a tool `<name>` the call does *not*
13
+ * offer (a model that names a tool it was never given), for the check that such a call runs
14
+ * nothing; once a result is in the prompt, a text answer that quotes it.
15
+ * - `fixture:budget`: a `budget_exhausted` failure.
16
+ * - `fixture:slow <seconds>`: a text answer after that long (at most 60 s), or nothing once
17
+ * the call is aborted, for cancel checks.
18
+ * - Anything else keeps the release demo: with a `lookup_order` tool it looks up order
19
+ * `demo-123` once, then answers with the result; on a later user message it says what the
20
+ * first one was; otherwise it says hello.
21
+ */
22
+ import type { ModelCandidate, ModelFailureOutcome } from "@nylorun/core/harness-api";
23
+ import type { RuntimeModelCall } from "../contracts.js";
24
+ export declare const FIXTURE_PROVIDER = "fixture";
25
+ type Answer = ModelCandidate | ModelFailureOutcome;
26
+ /** The fixture model's answer to `call`. */
27
+ export declare function fixtureAnswer(call: RuntimeModelCall, signal?: AbortSignal): Promise<Answer>;
28
+ export {};
@@ -0,0 +1,92 @@
1
+ export const FIXTURE_PROVIDER = "fixture";
2
+ const MARKER = /^fixture:(\w+)(?:\s+([\s\S]*))?$/;
3
+ /** The fixture model's answer to `call`. */
4
+ export async function fixtureAnswer(call, signal) {
5
+ const last = call.prompt.at(-1);
6
+ const users = call.prompt.filter((item) => item.kind === "message" && item.role === "user");
7
+ const marker = MARKER.exec(textOf(users.at(-1)?.content ?? []).trim());
8
+ if (marker)
9
+ return scripted(call, marker[1], marker[2]?.trim() ?? "", last, signal);
10
+ if (last?.kind === "tool-result")
11
+ return candidate(call, `Order lookup complete: ${textOf(last.content, true)}`);
12
+ if (users.length > 1)
13
+ return candidate(call, `I remember: ${textOf(users[0].content)}`);
14
+ if (!call.tools.some((tool) => tool.name === "lookup_order"))
15
+ return candidate(call, "Hello from the fixture model.");
16
+ return candidate(call, [{ type: "tool-call", id: "fixture_lookup", name: "lookup_order", args: { orderId: "demo-123" } }]);
17
+ }
18
+ async function scripted(call, kind, rest, last, signal) {
19
+ switch (kind) {
20
+ case "text":
21
+ return candidate(call, "fixture ok");
22
+ case "tool":
23
+ case "unoffered": {
24
+ if (last?.kind === "tool-result")
25
+ return candidate(call, `fixture tool ${last.status}: ${textOf(last.content, true)}`);
26
+ const space = rest.indexOf(" ");
27
+ const name = space < 0 ? rest : rest.slice(0, space);
28
+ const offered = call.tools.some((tool) => tool.name === name);
29
+ if (kind === "tool" && !offered)
30
+ return candidate(call, `fixture: no tool ${name} is offered`);
31
+ if (kind === "unoffered" && offered)
32
+ return candidate(call, `fixture: tool ${name} is offered`);
33
+ let args = {};
34
+ try {
35
+ if (space >= 0)
36
+ args = JSON.parse(rest.slice(space + 1));
37
+ }
38
+ catch {
39
+ return candidate(call, "fixture: the tool arguments are not JSON");
40
+ }
41
+ if (typeof args !== "object" || args === null || Array.isArray(args))
42
+ return candidate(call, "fixture: the tool arguments are not an object");
43
+ return candidate(call, [{ type: "tool-call", id: "fixture_call", name, args: args }]);
44
+ }
45
+ case "budget":
46
+ return {
47
+ kind: "failed",
48
+ code: "budget_exhausted",
49
+ message: "The fixture model's budget is exhausted (fixture:budget)",
50
+ retryable: false,
51
+ };
52
+ case "slow": {
53
+ const seconds = Math.min(Math.max(Number(rest) || 0, 0), 60);
54
+ await sleep(seconds * 1000, signal);
55
+ return candidate(call, `fixture slept ${seconds} s`);
56
+ }
57
+ default:
58
+ return candidate(call, `fixture: unknown marker ${kind}`);
59
+ }
60
+ }
61
+ function candidate(call, output) {
62
+ const blocks = typeof output === "string" ? [{ type: "text", text: output }] : output;
63
+ const inputTokens = tokens(JSON.stringify(call.prompt));
64
+ const outputTokens = tokens(JSON.stringify(blocks));
65
+ return {
66
+ output: blocks,
67
+ finishReason: blocks.some((block) => block.type === "tool-call") ? "tool-calls" : "stop",
68
+ usage: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, costUsd: 0 },
69
+ evidence: {
70
+ resolvedModel: FIXTURE_PROVIDER,
71
+ extras: { producer: { provider: FIXTURE_PROVIDER, model: FIXTURE_PROVIDER }, costKnown: true },
72
+ },
73
+ };
74
+ }
75
+ /** About four characters a token, at least one. */
76
+ const tokens = (text) => Math.max(1, Math.ceil(text.length / 4));
77
+ function textOf(content, json = false) {
78
+ return content
79
+ .map((part) => (part.type === "text" ? (part.text ?? "") : json ? JSON.stringify(part) : ""))
80
+ .join(" ");
81
+ }
82
+ function sleep(ms, signal) {
83
+ return new Promise((resolve, reject) => {
84
+ if (signal?.aborted)
85
+ return reject(signal.reason);
86
+ const timer = setTimeout(resolve, ms);
87
+ signal?.addEventListener("abort", () => {
88
+ clearTimeout(timer);
89
+ reject(signal.reason);
90
+ }, { once: true });
91
+ });
92
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Serves one model call in the gates service: opens the Tenant's vault (the database's one
3
- * Tenant; a call naming another is refused) and calls the model
4
- * through `inProcessModelGate`, the same code the loop ran before the gate, so outcomes,
3
+ * Tenant; a call naming another is refused) and calls the model (the fixture model while the
4
+ * Tenant's `model.fixture` is on, R4.3) through `inProcessModelGate`, the same code the loop ran before the gate, so outcomes,
5
5
  * retries, classification and redaction are unchanged. The meter records the call's usage in
6
6
  * the Tenant's ledger. Logs one line per call, never the prompt, the output or a credential.
7
7
  */
8
8
  import { classifyThrown } from "../model/classify.js";
9
- import { callFiles, inProcessModelGate } from "./in-process.js";
9
+ import { callFiles, inProcessModelGate, serve } from "./in-process.js";
10
10
  import { createMeter } from "./meter.js";
11
11
  import { GateRefusal } from "./tenant-vaults.js";
12
12
  export function createModelCallHandler(options) {
@@ -28,7 +28,7 @@ export function createModelCallHandler(options) {
28
28
  ...(settings ? { settings } : {}),
29
29
  ...(blobs ? { files: callFiles(vault.store, blobs) } : {}),
30
30
  });
31
- outcome = await meter.call(vault.store, call, () => gate.call(call, signal));
31
+ outcome = await meter.call(vault.store, call, () => serve(vault.store, gate, call, signal));
32
32
  }
33
33
  catch (error) {
34
34
  if (signal.aborted) {
@@ -17,9 +17,8 @@ export type HttpOutcome = {
17
17
  readonly kind: "failed";
18
18
  readonly code: string;
19
19
  readonly message: string;
20
- /** With `credential_rejected`: the tool's `credential`, and the scope of its vault. */
20
+ /** With `credential_rejected`: the tool's `credential`. */
21
21
  readonly server?: string;
22
- readonly vault?: "installation" | "user";
23
22
  /** How many credential values the message had in it, replaced (R2b C8). */
24
23
  readonly redacted?: number;
25
24
  };
@@ -45,6 +44,11 @@ export interface HttpToolCall {
45
44
  readonly effectId: string;
46
45
  readonly turnId: string;
47
46
  readonly input: unknown;
47
+ /**
48
+ * The call's span context (R7.4), sent as `traceparent`: in the gates service, from the call's
49
+ * journal row (`gates/journal.ts`); in process, from core's `execute` answer. Never a body field.
50
+ */
51
+ readonly traceparent?: string;
48
52
  }
49
53
  /** What `runHttpTool` reads of the Tenant: the session's pinned manifest and its credentials. */
50
54
  export interface HttpToolTenant {
@@ -163,7 +163,6 @@ export async function runHttpTool(tenant, call, options) {
163
163
  let credential = {};
164
164
  let identity;
165
165
  let target = http;
166
- let vault;
167
166
  if (http.credential !== undefined) {
168
167
  const authorized = await tenant.authorize(tool.sessionId, {
169
168
  url: http.url,
@@ -172,10 +171,9 @@ export async function runHttpTool(tenant, call, options) {
172
171
  if (authorized.status === "refused")
173
172
  return failed("http.credential", `The credential '${http.credential}' was refused: ${authorized.reason}`);
174
173
  if (authorized.status === "unauthenticated")
175
- return failed("http.credential", `The session's vaults hold no credential '${http.credential}' for ${authorized.url}`);
174
+ return failed("http.credential", `The session's Environment holds no credential '${http.credential}' for ${authorized.url}`);
176
175
  credential = authorized.headers;
177
176
  identity = authorized.identity;
178
- vault = authorized.vault;
179
177
  // A credential with `via` sends the call there, a gateway (R2b C2, Q9).
180
178
  if (authorized.via !== undefined)
181
179
  target = { ...http, url: authorized.via };
@@ -187,6 +185,7 @@ export async function runHttpTool(tenant, call, options) {
187
185
  [TURN_ID_HEADER]: call.turnId,
188
186
  [AGENT_ID_HEADER]: agentId ?? session.manifest.id,
189
187
  "idempotency-key": call.effectId,
188
+ ...(call.traceparent ? { traceparent: call.traceparent } : {}),
190
189
  },
191
190
  policy: options.policy,
192
191
  signal: options.signal,
@@ -199,9 +198,8 @@ export async function runHttpTool(tenant, call, options) {
199
198
  return outcome;
200
199
  return {
201
200
  ...outcome,
202
- message: `The service answered HTTP 401: it rejected the credential '${http.credential}' from the ${vault} vault`,
201
+ message: `The service answered HTTP 401: it rejected the credential '${http.credential}' from the session's Environment`,
203
202
  server: http.credential,
204
- ...(vault === undefined ? {} : { vault }),
205
203
  };
206
204
  }
207
205
  }
@@ -41,4 +41,9 @@ export declare function tenantModelGate(options: {
41
41
  /** The Tenant's Object store, for the files a prompt names. */
42
42
  readonly blobs?: BlobStore;
43
43
  }): ModelGate;
44
+ /**
45
+ * Serves one call: from the fixture model while the Tenant's `model.fixture` setting is on (R4.3),
46
+ * else from `gate`. Read per call, so a seed applies to the next call.
47
+ */
48
+ export declare function serve(store: SessionStore, gate: ModelGate, request: ModelGateRequest, signal: AbortSignal): ReturnType<ModelGate["call"]>;
44
49
  export declare function inProcessModelGate(options: InProcessModelGateOptions): ModelGate;
@@ -2,6 +2,8 @@ import { piModel } from "../model/pi-model.js";
2
2
  import { HostModelVault } from "../vault/host-model.js";
3
3
  import { createMeter } from "./meter.js";
4
4
  import { artifactFiles } from "../artifacts/files.js";
5
+ import { usesFixtureModel } from "../tenant/model-setting.js";
6
+ import { fixtureAnswer } from "./fixture-model.js";
5
7
  /**
6
8
  * The files a call names, from the Tenant's artifacts, limited to the call's session: in
7
9
  * process, the loop's own request; over HTTP, the session of the run token's claims, which the
@@ -26,9 +28,18 @@ export function tenantModelGate(options) {
26
28
  });
27
29
  const meter = createMeter({ logger: options.logger });
28
30
  return {
29
- call: (request, signal) => meter.call(options.store, request, () => gate.call(request, signal)),
31
+ call: (request, signal) => meter.call(options.store, request, () => serve(options.store, gate, request, signal)),
30
32
  };
31
33
  }
34
+ /**
35
+ * Serves one call: from the fixture model while the Tenant's `model.fixture` setting is on (R4.3),
36
+ * else from `gate`. Read per call, so a seed applies to the next call.
37
+ */
38
+ export async function serve(store, gate, request, signal) {
39
+ if (await store.tx((t) => usesFixtureModel(t)))
40
+ return fixtureAnswer(request.call, signal);
41
+ return gate.call(request, signal);
42
+ }
32
43
  export function inProcessModelGate(options) {
33
44
  return {
34
45
  async call(request, signal) {
@@ -0,0 +1,30 @@
1
+ import type { McpServerRef } from "../mcp/pool.js";
2
+ import type { RunClaims } from "../tenant/run-token.js";
3
+ import { type HttpToolRef } from "./http-tool.js";
4
+ import type { TenantVaults } from "./tenant-vaults.js";
5
+ /** The call a run asks the gate to send, as the gate's body names it. */
6
+ export type GateToolCall = {
7
+ readonly kind: "mcp";
8
+ readonly server: McpServerRef;
9
+ readonly name: string;
10
+ readonly arguments: Record<string, unknown>;
11
+ } | {
12
+ readonly kind: "http";
13
+ readonly tool: HttpToolRef;
14
+ readonly input: unknown;
15
+ };
16
+ /**
17
+ * What the journal says of a call: why it does not hold it, or that it does, with the call's span
18
+ * context from the row (R7.4), which the gate sends on as `traceparent`.
19
+ */
20
+ export type JournalAnswer = {
21
+ readonly refused: string;
22
+ } | {
23
+ readonly refused?: undefined;
24
+ readonly traceparent?: string;
25
+ };
26
+ export interface JournalCheck {
27
+ /** Whether the journal holds `call` under `key` for the run `claims` names. */
28
+ check(tenantId: string | undefined, claims: RunClaims, key: string, call: GateToolCall): Promise<JournalAnswer>;
29
+ }
30
+ export declare function journalCheck(vaults: TenantVaults): JournalCheck;