@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
@@ -0,0 +1,72 @@
1
+ import { type Server } from "node:http";
2
+ import { type Socket } from "node:net";
3
+ import type { EgressTokenVerdict } from "../sandbox/egress-token.js";
4
+ import type { SessionStore } from "../store/types.js";
5
+ import type { Logger } from "../tenant/types.js";
6
+ /**
7
+ * What egress-gate reads of a sandbox: its current host epoch, the pod that joined at it, and
8
+ * its spec's `network.allow`; `undefined` when it is gone, deleted, lost, expired or not a pod.
9
+ */
10
+ export interface EgressSandboxes {
11
+ live(sandboxId: string): Promise<{
12
+ readonly epoch: number;
13
+ readonly podUid?: string;
14
+ readonly allow: readonly string[];
15
+ } | undefined>;
16
+ }
17
+ /** Ports a tunnel may open. */
18
+ export declare const EGRESS_PORTS: readonly number[];
19
+ /** Tunnels one sandbox may hold open at once. */
20
+ export declare const EGRESS_MAX_PER_SANDBOX = 64;
21
+ /** A tunnel with no traffic either way for this long is closed. */
22
+ export declare const EGRESS_IDLE_MS: number;
23
+ /** How long a sandbox's epoch and allowlist are reused before they are read again. */
24
+ export declare const EGRESS_CACHE_MS = 5000;
25
+ /** How long the gate waits for the upstream connection. */
26
+ export declare const EGRESS_CONNECT_TIMEOUT_MS = 10000;
27
+ export interface StartEgressGateOptions {
28
+ readonly listen: {
29
+ readonly host: string;
30
+ readonly port: number;
31
+ };
32
+ readonly logger: Logger;
33
+ /** Verifies a presented egress token (`verifyEgressToken` over the Tenant's keys). */
34
+ verify(raw: string): Promise<EgressTokenVerdict>;
35
+ readonly sandboxes: EgressSandboxes;
36
+ readonly idleMs?: number;
37
+ readonly maxPerSandbox?: number;
38
+ readonly cacheMs?: number;
39
+ readonly connectTimeoutMs?: number;
40
+ /** Tests replace name resolution. */
41
+ readonly lookup?: (host: string) => Promise<readonly {
42
+ address: string;
43
+ family: number;
44
+ }[]>;
45
+ /** Tests replace which resolved addresses are refused. Default `isEgressBlocked`. */
46
+ readonly blocked?: (address: string) => boolean;
47
+ /** Tests replace how the checked address is dialled. */
48
+ readonly dial?: (address: string, port: number) => Socket;
49
+ }
50
+ export interface EgressGate {
51
+ /** Where the gate listens, e.g. `http://0.0.0.0:4200`. */
52
+ readonly url: string;
53
+ readonly server: Server;
54
+ close(): Promise<void>;
55
+ }
56
+ /**
57
+ * True when a tunnel must not reach `address`: private, loopback, link-local (169.254.169.254, the
58
+ * cloud metadata address, included), unspecified, multicast, reserved, or an IPv6 form that
59
+ * embeds an IPv4 address.
60
+ */
61
+ export declare function isEgressBlocked(address: string): boolean;
62
+ /** Whether `host` (a lowercase name) matches one of `allow`'s names or `*.suffix` patterns. */
63
+ export declare function egressAllows(host: string, allow: readonly string[]): boolean;
64
+ /** The egress token in a `Proxy-Authorization` header, or `undefined`. */
65
+ export declare function proxyToken(header: string | undefined): string | undefined;
66
+ export declare function startEgressGate(options: StartEgressGateOptions): Promise<EgressGate>;
67
+ /**
68
+ * `EgressSandboxes` over the Tenant's `sandbox_resources` (one indexed read): a pod sandbox's
69
+ * host epoch, the pod that joined at it and its spec's allowlist; nothing for a virtual
70
+ * sandbox, or one deleted, lost or expired, as `staleHost` judges a host token.
71
+ */
72
+ export declare function storeEgressSandboxes(store: SessionStore): EgressSandboxes;
@@ -0,0 +1,319 @@
1
+ /**
2
+ * egress-gate (F7.2, D42): the only way out of a pod sandbox to the internet. A CONNECT proxy in
3
+ * the gateway (`--service egress`, port 4200) that opens a TCP tunnel to a host the sandbox's spec
4
+ * allows (`network.allow`), and nothing else. Pods reach it through `HTTPS_PROXY`/`HTTP_PROXY`
5
+ * with their egress token as the proxy credential; NetworkPolicy lets them reach nothing but this
6
+ * listener, the gates and the Harness API.
7
+ *
8
+ * Every CONNECT, in order:
9
+ * 1. the egress token from `Proxy-Authorization` (Basic with the token as password, as tools send
10
+ * proxy userinfo, or Bearer) verifies (`verifyEgressToken`), else 407;
11
+ * 2. its sandbox is live and its host epoch and pod are the token's (`EgressSandboxes.live`),
12
+ * else 407;
13
+ * 3. the port is 443 or 80, the target is a host name and no IP literal, and it matches the spec's
14
+ * `network.allow` exactly or by `*.suffix`, else 403;
15
+ * 4. the sandbox has fewer than `maxPerSandbox` tunnels open, else 429;
16
+ * 5. the name resolves, and to at least one address that is not private, loopback, link-local
17
+ * (cloud metadata included), multicast or reserved, else 502 or 403;
18
+ * 6. the gate connects to that checked address (never to the name again) and pipes.
19
+ *
20
+ * No TLS interception and no credential injection: a tunnel is opaque bytes. Any other request
21
+ * (an absolute-form plain HTTP request included) is answered 405. Refusals are logged, never as
22
+ * events; the token never is. Tunnels close after `idleMs` without traffic in either direction.
23
+ */
24
+ import { lookup as dnsLookup } from "node:dns/promises";
25
+ import { createServer } from "node:http";
26
+ import { BlockList, connect, isIP } from "node:net";
27
+ import { isSandboxHostPattern } from "@nylorun/core/define";
28
+ import { isPrivateAddress } from "../tenant/outbound.js";
29
+ import { bindListener } from "../host/http.js";
30
+ /** Ports a tunnel may open. */
31
+ export const EGRESS_PORTS = [443, 80];
32
+ /** Tunnels one sandbox may hold open at once. */
33
+ export const EGRESS_MAX_PER_SANDBOX = 64;
34
+ /** A tunnel with no traffic either way for this long is closed. */
35
+ export const EGRESS_IDLE_MS = 5 * 60_000;
36
+ /** How long a sandbox's epoch and allowlist are reused before they are read again. */
37
+ export const EGRESS_CACHE_MS = 5_000;
38
+ /** How long the gate waits for the upstream connection. */
39
+ export const EGRESS_CONNECT_TIMEOUT_MS = 10_000;
40
+ /** Ranges beyond `isPrivateAddress`'s that a tunnel never reaches. */
41
+ const RESERVED = new BlockList();
42
+ for (const [network, prefix] of [
43
+ ["192.0.0.0", 24], // IETF protocol assignments
44
+ ["192.0.2.0", 24], // documentation
45
+ ["198.18.0.0", 15], // benchmarking
46
+ ["198.51.100.0", 24],
47
+ ["203.0.113.0", 24],
48
+ ["224.0.0.0", 4], // multicast
49
+ ["240.0.0.0", 4], // reserved, broadcast
50
+ ])
51
+ RESERVED.addSubnet(network, prefix, "ipv4");
52
+ for (const [network, prefix] of [
53
+ ["64:ff9b::", 96], // NAT64: would reach any IPv4 address
54
+ ["64:ff9b:1::", 48],
55
+ ["100::", 64], // discard
56
+ ["2001::", 32], // Teredo
57
+ ["2001:db8::", 32], // documentation
58
+ ["2002::", 16], // 6to4
59
+ ["ff00::", 8], // multicast
60
+ ])
61
+ RESERVED.addSubnet(network, prefix, "ipv6");
62
+ /**
63
+ * True when a tunnel must not reach `address`: private, loopback, link-local (169.254.169.254, the
64
+ * cloud metadata address, included), unspecified, multicast, reserved, or an IPv6 form that
65
+ * embeds an IPv4 address.
66
+ */
67
+ export function isEgressBlocked(address) {
68
+ if (isPrivateAddress(address))
69
+ return true;
70
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
71
+ if (mapped)
72
+ return RESERVED.check(mapped[1], "ipv4");
73
+ const family = isIP(address);
74
+ if (family === 0)
75
+ return true;
76
+ return RESERVED.check(address, family === 4 ? "ipv4" : "ipv6");
77
+ }
78
+ /** Whether `host` (a lowercase name) matches one of `allow`'s names or `*.suffix` patterns. */
79
+ export function egressAllows(host, allow) {
80
+ for (const entry of allow) {
81
+ const pattern = entry.toLowerCase();
82
+ if (pattern.startsWith("*.") ? host.endsWith(pattern.slice(1)) : host === pattern)
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+ /** The egress token in a `Proxy-Authorization` header, or `undefined`. */
88
+ export function proxyToken(header) {
89
+ if (!header)
90
+ return undefined;
91
+ const match = /^(basic|bearer)\s+(\S+)\s*$/i.exec(header);
92
+ if (!match)
93
+ return undefined;
94
+ if (match[1].toLowerCase() === "bearer")
95
+ return match[2];
96
+ const decoded = Buffer.from(match[2], "base64").toString("utf8");
97
+ const colon = decoded.indexOf(":");
98
+ // userinfo `x:<token>@`; a bare `<token>@` arrives as the user with no password.
99
+ const token = colon === -1 ? decoded : decoded.slice(colon + 1) || decoded.slice(0, colon);
100
+ return token === "" ? undefined : token;
101
+ }
102
+ /** `host:port` of a CONNECT request target, lowercase, without a trailing root dot. */
103
+ function parseTarget(target) {
104
+ const match = /^(\[[^\]]*\]|[^:[\]]+):(\d{1,5})$/.exec(target ?? "");
105
+ if (!match)
106
+ return undefined;
107
+ const port = Number(match[2]);
108
+ if (port < 1 || port > 65535)
109
+ return undefined;
110
+ return { host: match[1].toLowerCase().replace(/\.$/, ""), port };
111
+ }
112
+ const REASONS = {
113
+ 403: "Forbidden",
114
+ 405: "Method Not Allowed",
115
+ 407: "Proxy Authentication Required",
116
+ 429: "Too Many Requests",
117
+ 502: "Bad Gateway",
118
+ };
119
+ function answer(socket, status, message, headers = []) {
120
+ const body = `egress-gate: ${message}\n`;
121
+ if (socket.destroyed)
122
+ return;
123
+ socket.end([
124
+ `HTTP/1.1 ${status} ${REASONS[status] ?? "Error"}`,
125
+ "content-type: text/plain; charset=utf-8",
126
+ `content-length: ${Buffer.byteLength(body)}`,
127
+ "connection: close",
128
+ ...headers,
129
+ "",
130
+ body,
131
+ ].join("\r\n"));
132
+ }
133
+ export async function startEgressGate(options) {
134
+ const { logger, sandboxes } = options;
135
+ const idleMs = options.idleMs ?? EGRESS_IDLE_MS;
136
+ const maxPerSandbox = options.maxPerSandbox ?? EGRESS_MAX_PER_SANDBOX;
137
+ const cacheMs = options.cacheMs ?? EGRESS_CACHE_MS;
138
+ const connectTimeoutMs = options.connectTimeoutMs ?? EGRESS_CONNECT_TIMEOUT_MS;
139
+ const blocked = options.blocked ?? isEgressBlocked;
140
+ const lookup = options.lookup ?? ((host) => dnsLookup(host, { all: true, verbatim: true }));
141
+ const dial = options.dial ?? ((address, port) => connect({ host: address, port }));
142
+ const cache = new Map();
143
+ /** The sandbox as of at most `cacheMs` ago; read again when the token names a newer epoch. */
144
+ async function live(sandboxId, epoch) {
145
+ const cached = cache.get(sandboxId);
146
+ if (cached && Date.now() - cached.at < cacheMs && cached.value && cached.value.epoch >= epoch)
147
+ return cached.value;
148
+ const value = await sandboxes.live(sandboxId);
149
+ cache.set(sandboxId, { at: Date.now(), value });
150
+ if (cache.size > 4096)
151
+ cache.delete(cache.keys().next().value);
152
+ return value;
153
+ }
154
+ const open = new Map();
155
+ const tunnels = new Set();
156
+ async function onConnect(req, client, head) {
157
+ const target = parseTarget(req.url);
158
+ const refuse = (status, reason, fields = {}, message) => {
159
+ logger.warn("egress_refused", { status, reason, ...(target ? { host: target.host, port: target.port } : {}), ...fields });
160
+ answer(client, status, message ?? reason, status === 407 ? ['proxy-authenticate: Basic realm="nylorun-egress"'] : []);
161
+ };
162
+ const raw = proxyToken(req.headers["proxy-authorization"]);
163
+ if (!raw)
164
+ return refuse(407, "egress_token_missing", {}, "an egress token is required");
165
+ const verdict = await options.verify(raw);
166
+ if (!verdict.ok)
167
+ return refuse(407, verdict.reason, {}, "the egress token was refused");
168
+ const { sandboxId, epoch } = verdict.claims;
169
+ const sandbox = await live(sandboxId, epoch);
170
+ if (!sandbox)
171
+ return refuse(407, "sandbox_gone", { sandboxId }, "the egress token was refused");
172
+ if (sandbox.epoch !== epoch)
173
+ return refuse(407, "epoch_stale", { sandboxId, epoch, current: sandbox.epoch }, "the egress token was refused");
174
+ if (sandbox.podUid !== undefined && sandbox.podUid !== verdict.claims.podUid)
175
+ return refuse(407, "pod_stale", { sandboxId }, "the egress token was refused");
176
+ if (!target)
177
+ return refuse(403, "target_invalid", { sandboxId }, "CONNECT needs host:port");
178
+ const { host, port } = target;
179
+ if (!EGRESS_PORTS.includes(port))
180
+ return refuse(403, "port_refused", { sandboxId }, `port ${port} is not allowed; tunnels open to 443 or 80 only`);
181
+ if (isIP(host.replace(/^\[|\]$/g, "")) !== 0 || !isSandboxHostPattern(host) || host.startsWith("*."))
182
+ return refuse(403, "not_a_host_name", { sandboxId }, `${host} is not a host name; IP literals are refused`);
183
+ if (!egressAllows(host, sandbox.allow))
184
+ return refuse(403, "host_not_allowed", { sandboxId }, `${host} is not in this sandbox's network.allow`);
185
+ let mine = open.get(sandboxId);
186
+ if (!mine)
187
+ open.set(sandboxId, (mine = new Set()));
188
+ if (mine.size >= maxPerSandbox)
189
+ return refuse(429, "too_many_tunnels", { sandboxId }, `this sandbox has ${maxPerSandbox} tunnels open`);
190
+ const slot = mine;
191
+ slot.add(client);
192
+ tunnels.add(client);
193
+ const release = () => {
194
+ slot.delete(client);
195
+ tunnels.delete(client);
196
+ if (slot.size === 0 && open.get(sandboxId) === slot)
197
+ open.delete(sandboxId);
198
+ };
199
+ client.once("close", release);
200
+ let addresses;
201
+ try {
202
+ addresses = await lookup(host);
203
+ }
204
+ catch (error) {
205
+ return refuse(502, "resolve_failed", { sandboxId, code: error.code }, `${host} does not resolve`);
206
+ }
207
+ const allowed = addresses.filter((entry) => !blocked(entry.address));
208
+ if (allowed.length === 0)
209
+ return refuse(403, "address_refused", { sandboxId }, `${host} resolves only to addresses tunnels may not reach`);
210
+ if (client.destroyed)
211
+ return;
212
+ const upstream = await dialFirst(allowed.map((entry) => entry.address), port);
213
+ if (!upstream)
214
+ return refuse(502, "connect_failed", { sandboxId }, `could not connect to ${host}:${port}`);
215
+ if (client.destroyed) {
216
+ upstream.destroy();
217
+ return;
218
+ }
219
+ upstream.setTimeout(idleMs);
220
+ const close = () => {
221
+ client.destroy();
222
+ upstream.destroy();
223
+ };
224
+ upstream.on("timeout", close);
225
+ upstream.on("error", close);
226
+ upstream.on("close", () => client.destroy());
227
+ client.on("close", () => upstream.destroy());
228
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
229
+ if (head.length > 0)
230
+ upstream.write(head);
231
+ upstream.pipe(client);
232
+ client.pipe(upstream);
233
+ }
234
+ /** Connects to the first of `addresses` that answers within the connect timeout. */
235
+ async function dialFirst(addresses, port) {
236
+ for (const address of addresses) {
237
+ const socket = await new Promise((resolve) => {
238
+ const attempt = dial(address, port);
239
+ const timer = setTimeout(() => {
240
+ attempt.destroy();
241
+ resolve(undefined);
242
+ }, connectTimeoutMs);
243
+ attempt.once("connect", () => {
244
+ clearTimeout(timer);
245
+ attempt.removeAllListeners("error");
246
+ resolve(attempt);
247
+ });
248
+ attempt.once("error", () => {
249
+ clearTimeout(timer);
250
+ attempt.destroy();
251
+ resolve(undefined);
252
+ });
253
+ });
254
+ if (socket)
255
+ return socket;
256
+ }
257
+ return undefined;
258
+ }
259
+ const server = createServer((req, res) => {
260
+ logger.warn("egress_refused", { status: 405, reason: "not_connect", method: req.method });
261
+ res.writeHead(405, { allow: "CONNECT", "content-type": "text/plain; charset=utf-8", connection: "close" });
262
+ res.end("egress-gate: only CONNECT is served; set HTTPS_PROXY and HTTP_PROXY to this address\n");
263
+ });
264
+ server.headersTimeout = 10_000;
265
+ server.requestTimeout = 15_000;
266
+ server.on("connect", (req, client, head) => {
267
+ // A client socket: idle from the request on, through the tunnel's life.
268
+ client.on("error", () => { });
269
+ client.setTimeout(idleMs);
270
+ client.on("timeout", () => client.destroy());
271
+ onConnect(req, client, head).catch((error) => {
272
+ logger.error("egress_failed", { error: error instanceof Error ? error.message : String(error) });
273
+ answer(client, 502, "the tunnel failed");
274
+ });
275
+ });
276
+ server.on("clientError", (_error, socket) => {
277
+ if (!socket.destroyed)
278
+ socket.end("HTTP/1.1 400 Bad Request\r\nconnection: close\r\n\r\n");
279
+ });
280
+ await bindListener(server, options.listen.port, options.listen.host, (error) => logger.error("listener_error", { error: error.message }));
281
+ const port = server.address().port;
282
+ const host = options.listen.host.includes(":") ? `[${options.listen.host}]` : options.listen.host;
283
+ let closing;
284
+ return {
285
+ url: `http://${host}:${port}`,
286
+ server,
287
+ close() {
288
+ closing ??= new Promise((resolve) => {
289
+ server.close(() => resolve());
290
+ for (const tunnel of tunnels)
291
+ tunnel.destroy();
292
+ server.closeAllConnections();
293
+ });
294
+ return closing;
295
+ },
296
+ };
297
+ }
298
+ /**
299
+ * `EgressSandboxes` over the Tenant's `sandbox_resources` (one indexed read): a pod sandbox's
300
+ * host epoch, the pod that joined at it and its spec's allowlist; nothing for a virtual
301
+ * sandbox, or one deleted, lost or expired, as `staleHost` judges a host token.
302
+ */
303
+ export function storeEgressSandboxes(store) {
304
+ return {
305
+ async live(sandboxId) {
306
+ const sandbox = await store.tx((t) => t.sandboxResource(sandboxId));
307
+ const pod = sandbox?.pod;
308
+ if (!sandbox || sandbox.kind !== "pod" || !pod)
309
+ return undefined;
310
+ if (pod.desired === "deleted" || pod.observed === "lost" || pod.observed === "expired")
311
+ return undefined;
312
+ return {
313
+ epoch: pod.hostEpoch,
314
+ ...(pod.podUid ? { podUid: pod.podUid } : {}),
315
+ allow: sandbox.spec.network?.allow ?? [],
316
+ };
317
+ },
318
+ };
319
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Logger } from "../tenant/types.js";
2
+ import type { BlobStore } from "../blob/index.js";
2
3
  import type { ModelCallSettings, ModelGate, ModelGateRequest } from "./model-gate.js";
3
4
  import { type TenantVaults } from "./tenant-vaults.js";
4
5
  export interface ModelCallHandlerOptions {
@@ -6,6 +7,8 @@ export interface ModelCallHandlerOptions {
6
7
  readonly logger: Logger;
7
8
  /** Retries and timeouts; `piModel`'s defaults when absent. */
8
9
  readonly settings?: ModelCallSettings;
10
+ /** The Object store, for the files a prompt names (protocol 6). */
11
+ readonly blobs?: BlobStore;
9
12
  }
10
13
  /**
11
14
  * A model call as the gate receives it: the Tenant is optional (the loop of a protocol 4
@@ -6,11 +6,11 @@
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 { inProcessModelGate } from "./in-process.js";
9
+ import { callFiles, inProcessModelGate } 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) {
13
- const { vaults, logger, settings } = options;
13
+ const { vaults, logger, settings, blobs } = options;
14
14
  const meter = createMeter({ logger });
15
15
  return {
16
16
  async call(named, signal) {
@@ -26,6 +26,7 @@ export function createModelCallHandler(options) {
26
26
  readHostModel: () => vault.readHostModel(),
27
27
  writeHostCredential: (credential) => vault.writeHostCredential(credential),
28
28
  ...(settings ? { settings } : {}),
29
+ ...(blobs ? { files: callFiles(vault.store, blobs) } : {}),
29
30
  });
30
31
  outcome = await meter.call(vault.store, call, () => gate.call(call, signal));
31
32
  }
@@ -1,11 +1,12 @@
1
1
  import type { ModelGate } from "./model-gate.js";
2
+ import type { RunTokens } from "../tenant/run-grants.js";
2
3
  /** Above the gate's 600 s provider request timeout. */
3
4
  export declare const GATE_CLIENT_TIMEOUT_MS = 630000;
4
5
  export interface HttpModelGateOptions {
5
6
  /** The gates service, e.g. `http://gateway:4100` (`NYLORUN_GATES_URL`). */
6
7
  readonly url: string;
7
- /** `NYLORUN_GATES_TOKEN`. */
8
- readonly token: string;
8
+ /** The run token of each session an advance of this process owns: a call's credential. */
9
+ readonly runTokens: RunTokens;
9
10
  /** How long the connection may stay silent. Default `GATE_CLIENT_TIMEOUT_MS`. */
10
11
  readonly timeoutMs?: number;
11
12
  }
@@ -10,6 +10,10 @@
10
10
  * A failure of the hop is a failure outcome, never a throw: `resolveEffect` marks anything
11
11
  * thrown `uncertain`. Only an abort of the caller's signal throws. The client never retries;
12
12
  * the gate retries the provider.
13
+ *
14
+ * The credential is the session's run token (F5), read per request from the advance's grant:
15
+ * the gate takes session, turn and agent from it, so the body no longer names them. A call for
16
+ * a session without a grant fails at once; it never falls back to core's credential.
13
17
  */
14
18
  import { request as httpRequest } from "node:http";
15
19
  import { request as httpsRequest } from "node:https";
@@ -27,12 +31,16 @@ export function httpModelGate(options) {
27
31
  return {
28
32
  recovers: true,
29
33
  async cancel(request) {
34
+ const token = options.runTokens.token(request.sessionId);
35
+ // Without a grant there is no run whose call to stop.
36
+ if (!token)
37
+ return;
30
38
  const url = new URL(`${MODEL_CALLS_PATH}/${encodeURIComponent(request.effectId)}/cancel`, options.url);
31
39
  await new Promise((resolve) => {
32
40
  const outgoing = send(url, {
33
41
  method: "POST",
34
42
  headers: {
35
- authorization: `Bearer ${options.token}`,
43
+ authorization: `Bearer ${token}`,
36
44
  [TENANT_HEADER]: request.tenantId,
37
45
  "content-length": 0,
38
46
  },
@@ -49,10 +57,11 @@ export function httpModelGate(options) {
49
57
  },
50
58
  async call(request, signal) {
51
59
  signal.throwIfAborted();
60
+ const token = options.runTokens.token(request.sessionId);
61
+ // Never core's credential: like an unreachable gate, and the heartbeat mints it again.
62
+ if (!token)
63
+ return failure("transient", `No run token for session ${request.sessionId}: the advance that owns it could not mint one, or the call came from outside an owned advance`, true);
52
64
  const body = {
53
- sessionId: request.sessionId,
54
- turnId: request.turnId,
55
- agentId: request.agentId,
56
65
  effectId: request.effectId,
57
66
  invocationId: request.invocationId,
58
67
  call: request.call,
@@ -87,7 +96,7 @@ export function httpModelGate(options) {
87
96
  const outgoing = send(endpoint, {
88
97
  method: "POST",
89
98
  headers: {
90
- authorization: `Bearer ${options.token}`,
99
+ authorization: `Bearer ${token}`,
91
100
  "content-type": "application/json",
92
101
  "content-length": payload.byteLength,
93
102
  [TENANT_HEADER]: request.tenantId,
@@ -151,7 +160,9 @@ function answer(status, text) {
151
160
  }
152
161
  const message = parsed?.error?.message;
153
162
  if (status === 401)
154
- return failure("auth", "The model gate refused this Runtime's token: the runtime and gateway containers must share NYLORUN_GATES_TOKEN", false);
163
+ return failure("auth", `The model gate refused the run token${message ? `: ${message}` : ""}`, false);
164
+ if (status === 409 && parsed?.error?.code === "run_stale")
165
+ return failure("invalid_request", `The model gate refused a stale run${message ? `: ${message}` : ""}`, false);
155
166
  if (status === 409)
156
167
  return failure("invalid_request", `The call's request changed under the same effect id${message ? `: ${message}` : ""}`, false);
157
168
  if (status === 400)
@@ -8,7 +8,9 @@ import { type ModelCallSettings } from "../model/pi-model.js";
8
8
  import type { SessionStore } from "../store/types.js";
9
9
  import type { Logger } from "../tenant/types.js";
10
10
  import type { HostModelSecret } from "../vault/service.js";
11
- import type { ModelGate } from "./model-gate.js";
11
+ import type { ModelGate, ModelGateRequest } from "./model-gate.js";
12
+ import { type FileResolver } from "../artifacts/files.js";
13
+ import type { BlobStore } from "../blob/index.js";
12
14
  export interface InProcessModelGateOptions {
13
15
  /** The Tenant's home: `piModel` redacts secrets it finds there from failure messages. */
14
16
  readonly root?: string;
@@ -16,7 +18,15 @@ export interface InProcessModelGateOptions {
16
18
  /** Writes back a credential pi-ai refreshed (OAuth). */
17
19
  readonly writeHostCredential: (credential: Credential) => Promise<void>;
18
20
  readonly settings?: ModelCallSettings;
21
+ /** Reads the files a call's prompt names (protocol 6): artifacts, for model-gate. */
22
+ readonly files?: (request: ModelGateRequest) => FileResolver;
19
23
  }
24
+ /**
25
+ * The files a call names, from the Tenant's artifacts, limited to the call's session: in
26
+ * process, the loop's own request; over HTTP, the session of the run token's claims, which the
27
+ * gate's route puts on the request (F5). A request without a session reads no file at all.
28
+ */
29
+ export declare function callFiles(store: SessionStore, blobs: BlobStore): (request: Pick<ModelGateRequest, "sessionId">) => FileResolver;
20
30
  /**
21
31
  * The in-process gate of an open Tenant: reads its host model from its own vault and records
22
32
  * each call in its usage ledger. Only a Runtime without the gates service (embedding, the
@@ -28,5 +38,7 @@ export declare function tenantModelGate(options: {
28
38
  readonly root: string;
29
39
  readonly logger: Logger;
30
40
  readonly settings?: ModelCallSettings;
41
+ /** The Tenant's Object store, for the files a prompt names. */
42
+ readonly blobs?: BlobStore;
31
43
  }): ModelGate;
32
44
  export declare function inProcessModelGate(options: InProcessModelGateOptions): ModelGate;
@@ -1,6 +1,15 @@
1
1
  import { piModel } from "../model/pi-model.js";
2
2
  import { HostModelVault } from "../vault/host-model.js";
3
3
  import { createMeter } from "./meter.js";
4
+ import { artifactFiles } from "../artifacts/files.js";
5
+ /**
6
+ * The files a call names, from the Tenant's artifacts, limited to the call's session: in
7
+ * process, the loop's own request; over HTTP, the session of the run token's claims, which the
8
+ * gate's route puts on the request (F5). A request without a session reads no file at all.
9
+ */
10
+ export function callFiles(store, blobs) {
11
+ return (request) => artifactFiles({ store, blobs, sessionId: request.sessionId });
12
+ }
4
13
  /**
5
14
  * The in-process gate of an open Tenant: reads its host model from its own vault and records
6
15
  * each call in its usage ledger. Only a Runtime without the gates service (embedding, the
@@ -13,6 +22,7 @@ export function tenantModelGate(options) {
13
22
  readHostModel: () => vault.readHostModel(),
14
23
  writeHostCredential: (credential) => vault.updateHostCredential(credential),
15
24
  ...(options.settings ? { settings: options.settings } : {}),
25
+ ...(options.blobs ? { files: callFiles(options.store, options.blobs) } : {}),
16
26
  });
17
27
  const meter = createMeter({ logger: options.logger });
18
28
  return {
@@ -28,6 +38,7 @@ export function inProcessModelGate(options) {
28
38
  readHostModel: options.readHostModel,
29
39
  writeHostCredential: options.writeHostCredential,
30
40
  ...(options.settings ? { settings: options.settings } : {}),
41
+ ...(options.files ? { files: options.files(request) } : {}),
31
42
  });
32
43
  return adapter(request.call, {
33
44
  // The Runtime never journals the provider request (harness `durable.ts`), and
@@ -8,22 +8,41 @@
8
8
  * In memory, one gateway only (M2): a gateway restart loses these, and a re-sent call runs
9
9
  * again (the ledger flags the duplicate). Outcomes are kept `ttlMs` after they settle; a call
10
10
  * that throws (an abort) is not kept.
11
+ *
12
+ * A call sent under a run token (F5) records the session and lease epoch that started it. A
13
+ * re-send joins it only for the same session at the same or a newer epoch (the new owner after
14
+ * a takeover, G4), and a cancel stops it only for the same session.
11
15
  */
12
16
  import type { ModelGateOutcome } from "./model-gate.js";
13
17
  /** A re-send whose request differs from the call already running under its key. */
14
18
  export declare class InflightConflict extends Error {
15
19
  readonly name = "InflightConflict";
16
20
  }
21
+ /** A re-send under an older lease epoch than the call's: never joins (G4). */
22
+ export declare class InflightStale extends Error {
23
+ readonly name = "InflightStale";
24
+ }
25
+ /** The run a keyed call belongs to: its session and the lease epoch that sent it. */
26
+ export interface InflightOwner {
27
+ readonly sessionId: string;
28
+ readonly epoch: number;
29
+ }
17
30
  export interface InflightCalls<T = ModelGateOutcome> {
18
31
  /**
19
32
  * Joins the call under `key`, or starts it with `start` under the entry's own signal.
20
- * Rejects with `InflightConflict` when `hash` differs from the running call's.
33
+ * Rejects with `InflightConflict` when `hash` differs from the running call's, or `owner`
34
+ * names another session than the call's, and with `InflightStale` when `owner`'s epoch is
35
+ * older than the call's. A call started without an owner (core's credential) is joined by
36
+ * hash alone.
21
37
  */
22
- run(key: string, hash: string, start: (signal: AbortSignal) => Promise<T>): Promise<T>;
38
+ run(key: string, hash: string, start: (signal: AbortSignal) => Promise<T>, owner?: InflightOwner): Promise<T>;
23
39
  /** True while a call runs under `key`, or its outcome is still kept. */
24
40
  has(key: string): boolean;
25
- /** Aborts the call under `key` and forgets it; nothing when there is none. */
26
- cancel(key: string): void;
41
+ /**
42
+ * Aborts the call under `key` and forgets it; nothing when there is none. With `sessionId`
43
+ * (a run's cancel), returns false and leaves the call alone when it is another session's.
44
+ */
45
+ cancel(key: string, sessionId?: string): boolean;
27
46
  /** Aborts every running call (gateway shutdown). */
28
47
  close(): void;
29
48
  /** Entries held: running, or settled within the TTL. */