@nylorun/runtime 0.9.0-beta → 0.10.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.
- package/CHANGELOG.md +35 -0
- package/README.md +96 -25
- package/dist/adapters/media.d.ts +2 -2
- package/dist/adapters/media.js +2 -2
- package/dist/adapters/observe.d.ts +2 -1
- package/dist/adapters/observe.js +2 -3
- package/dist/assets.d.ts +2 -2
- package/dist/assets.js +5 -3
- package/dist/core/executors.d.ts +11 -2
- package/dist/core/executors.js +20 -4
- package/dist/core/flow-host.d.ts +264 -0
- package/dist/core/flow-host.js +783 -0
- package/dist/core/limits.d.ts +40 -0
- package/dist/core/limits.js +76 -0
- package/dist/core/runtime.d.ts +5 -90
- package/dist/core/runtime.js +3 -1447
- package/dist/core/sandbox-routes.d.ts +35 -0
- package/dist/core/sandbox-routes.js +106 -0
- package/dist/core/store.d.ts +3 -1
- package/dist/core/store.js +14 -73
- package/dist/core/turn-manifest.d.ts +50 -0
- package/dist/core/turn-manifest.js +66 -0
- package/dist/host/config-for.d.ts +16 -0
- package/dist/host/config-for.js +45 -0
- package/dist/host/config.d.ts +24 -0
- package/dist/host/create-host.d.ts +21 -0
- package/dist/host/create-host.js +396 -0
- package/dist/host/environment.d.ts +13 -0
- package/dist/host/environment.js +53 -0
- package/dist/host/http.d.ts +35 -0
- package/dist/host/http.js +136 -0
- package/dist/host/index.d.ts +6 -0
- package/dist/host/index.js +5 -0
- package/dist/host/logger.d.ts +3 -0
- package/dist/host/logger.js +18 -0
- package/dist/host/main.d.ts +3 -0
- package/dist/host/main.js +142 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +4 -2
- package/dist/launcher/commands.d.ts +24 -0
- package/dist/launcher/commands.js +260 -0
- package/dist/launcher/ensure-config.d.ts +16 -0
- package/dist/launcher/ensure-config.js +79 -0
- package/dist/launcher/errors.d.ts +12 -0
- package/dist/launcher/errors.js +22 -0
- package/dist/launcher/host-config.d.ts +17 -0
- package/dist/launcher/host-config.js +64 -0
- package/dist/launcher/host-state.d.ts +17 -0
- package/dist/launcher/host-state.js +61 -0
- package/dist/launcher/lifecycle.d.ts +66 -0
- package/dist/launcher/lifecycle.js +407 -0
- package/dist/launcher/locks.d.ts +26 -0
- package/dist/launcher/locks.js +117 -0
- package/dist/launcher/logs.d.ts +18 -0
- package/dist/launcher/logs.js +117 -0
- package/dist/launcher/main.d.ts +2 -0
- package/dist/launcher/main.js +66 -0
- package/dist/launcher/output.d.ts +11 -0
- package/dist/launcher/output.js +68 -0
- package/dist/launcher/paths.d.ts +20 -0
- package/dist/launcher/paths.js +36 -0
- package/dist/launcher/probe.d.ts +23 -0
- package/dist/launcher/probe.js +72 -0
- package/dist/launcher/protocol.d.ts +62 -0
- package/dist/launcher/protocol.js +1 -0
- package/dist/launcher/schema-guard.d.ts +6 -0
- package/dist/launcher/schema-guard.js +36 -0
- package/dist/launcher/spawn.d.ts +22 -0
- package/dist/launcher/spawn.js +85 -0
- package/dist/launcher/status.d.ts +6 -0
- package/dist/launcher/status.js +65 -0
- package/dist/mcp/connect.d.ts +1 -0
- package/dist/mcp/connect.js +2 -0
- package/dist/mcp/pool.d.ts +3 -1
- package/dist/mcp/pool.js +2 -1
- package/dist/mcp/stdio.d.ts +2 -0
- package/dist/mcp/stdio.js +3 -1
- package/dist/model/auth-store.d.ts +1 -1
- package/dist/model/auth-store.js +2 -2
- package/dist/model/models.d.ts +4 -0
- package/dist/model/models.js +22 -26
- package/dist/model/pi-model.js +1 -1
- package/dist/model/settings.d.ts +5 -1
- package/dist/model/settings.js +6 -2
- package/dist/sandbox/select.d.ts +4 -3
- package/dist/sandbox/select.js +15 -9
- package/dist/sandbox/share.d.ts +23 -0
- package/dist/sandbox/share.js +58 -0
- package/dist/tenant/envelope.d.ts +10 -0
- package/dist/tenant/envelope.js +54 -0
- package/dist/tenant/ephemeral.d.ts +31 -0
- package/dist/tenant/ephemeral.js +156 -0
- package/dist/tenant/logger.d.ts +9 -0
- package/dist/tenant/logger.js +25 -0
- package/dist/tenant/migration.d.ts +16 -0
- package/dist/tenant/migration.js +116 -0
- package/dist/tenant/module.d.ts +17 -0
- package/dist/tenant/module.js +315 -0
- package/dist/tenant/paths.d.ts +24 -0
- package/dist/tenant/paths.js +65 -0
- package/dist/tenant/pool.d.ts +10 -0
- package/dist/tenant/pool.js +54 -0
- package/dist/tenant/principals.d.ts +17 -0
- package/dist/tenant/principals.js +44 -0
- package/dist/tenant/quarantine-error.d.ts +6 -0
- package/dist/tenant/quarantine-error.js +15 -0
- package/dist/tenant/quarantine.d.ts +35 -0
- package/dist/tenant/quarantine.js +94 -0
- package/dist/tenant/reset.d.ts +21 -0
- package/dist/tenant/reset.js +125 -0
- package/dist/tenant/runtime.d.ts +80 -0
- package/dist/tenant/runtime.js +2256 -0
- package/dist/tenant/schema.d.ts +23 -0
- package/dist/tenant/schema.js +151 -0
- package/dist/tenant/status.d.ts +28 -0
- package/dist/tenant/status.js +95 -0
- package/dist/tenant/store-fs.d.ts +16 -0
- package/dist/tenant/store-fs.js +205 -0
- package/dist/tenant/store-memory.d.ts +19 -0
- package/dist/tenant/store-memory.js +100 -0
- package/dist/tenant/types.d.ts +125 -0
- package/dist/tenant/types.js +1 -0
- package/dist/vault/kek.d.ts +5 -2
- package/dist/vault/kek.js +7 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -6
- package/dist/core/main.js +0 -35
- /package/dist/{core/main.d.ts → host/config.js} +0 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { unlinkSync, existsSync } from "node:fs";
|
|
4
|
+
import { checkCompatibility, HOST_PROTOCOL, isTenantId, PROTOCOL_HEADER, TENANT_HEADER, } from "@nylorun/core/compatibility";
|
|
5
|
+
import { AdminStatusSchema, CreateTenantRequestSchema, } from "@nylorun/core/contracts";
|
|
6
|
+
import { hostPaths } from "../tenant/paths.js";
|
|
7
|
+
import { TenantBusyError, TenantConflictError, } from "../tenant/quarantine.js";
|
|
8
|
+
import { EXIT_NON_LOOPBACK, EXIT_PORT_IN_USE, HostListenError, isAllowedRequestHost, isJsonContentType, isLoopbackHost, readBearer, readJsonBody, redactRoutePath, requestHasBody, sendJson, sendOpaqueNotFound, sendProtocolRejected, sendRejected, } from "./http.js";
|
|
9
|
+
import { RUNTIME_VERSION } from "../version.js";
|
|
10
|
+
function hashUtf8(value) {
|
|
11
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
12
|
+
}
|
|
13
|
+
/** Constant-time comparison of SHA-256 digests of the presented and stored admin keys. */
|
|
14
|
+
export function adminKeyMatches(presented, adminKey) {
|
|
15
|
+
if (presented === undefined)
|
|
16
|
+
return false;
|
|
17
|
+
const a = hashUtf8(presented);
|
|
18
|
+
const b = hashUtf8(adminKey);
|
|
19
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
20
|
+
}
|
|
21
|
+
function headerValue(headers, name) {
|
|
22
|
+
const raw = headers[name.toLowerCase()];
|
|
23
|
+
if (Array.isArray(raw))
|
|
24
|
+
return raw[0];
|
|
25
|
+
return raw;
|
|
26
|
+
}
|
|
27
|
+
function parseProtocolVersion(raw) {
|
|
28
|
+
if (raw === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
const trimmed = raw.trim();
|
|
31
|
+
if (!/^\d+$/.test(trimmed))
|
|
32
|
+
return undefined;
|
|
33
|
+
return Number(trimmed);
|
|
34
|
+
}
|
|
35
|
+
function protocolAccepted(raw) {
|
|
36
|
+
const version = parseProtocolVersion(raw);
|
|
37
|
+
if (version === undefined)
|
|
38
|
+
return false;
|
|
39
|
+
return checkCompatibility({ version, required: [] }, HOST_PROTOCOL).ok;
|
|
40
|
+
}
|
|
41
|
+
export function createHost(options) {
|
|
42
|
+
const { hostRoot, module, config, credentials, logger, coreVersion, } = options;
|
|
43
|
+
const pid = options.pid ?? process.pid;
|
|
44
|
+
const paths = hostPaths(hostRoot);
|
|
45
|
+
let server;
|
|
46
|
+
let url = "";
|
|
47
|
+
let listenPort = config.port;
|
|
48
|
+
let closing = false;
|
|
49
|
+
let closePromise;
|
|
50
|
+
const adminStatusBody = async () => {
|
|
51
|
+
const tenants = await module.list();
|
|
52
|
+
const aggregate = module.summarize();
|
|
53
|
+
return AdminStatusSchema.parse({
|
|
54
|
+
service: "nylorun-runtime",
|
|
55
|
+
version: RUNTIME_VERSION,
|
|
56
|
+
protocol: {
|
|
57
|
+
min: HOST_PROTOCOL.min,
|
|
58
|
+
max: HOST_PROTOCOL.max,
|
|
59
|
+
features: [...HOST_PROTOCOL.features],
|
|
60
|
+
},
|
|
61
|
+
tenants,
|
|
62
|
+
aggregate,
|
|
63
|
+
host: {
|
|
64
|
+
hostId: config.hostId,
|
|
65
|
+
url,
|
|
66
|
+
pid,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
const requireAdmin = (request, response) => {
|
|
71
|
+
const token = readBearer(headerValue(request.headers, "authorization"));
|
|
72
|
+
if (!adminKeyMatches(token, credentials.adminKey)) {
|
|
73
|
+
sendOpaqueNotFound(response);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
};
|
|
78
|
+
const handleAdmin = async (request, response, urlObj, segments) => {
|
|
79
|
+
const method = request.method ?? "GET";
|
|
80
|
+
// /v1/admin/...
|
|
81
|
+
if (segments[2] === "tenants") {
|
|
82
|
+
if (segments.length === 3 && method === "GET") {
|
|
83
|
+
const tenants = await module.list();
|
|
84
|
+
return sendJson(response, 200, tenants);
|
|
85
|
+
}
|
|
86
|
+
if (segments.length === 3 && method === "POST") {
|
|
87
|
+
const body = CreateTenantRequestSchema.parse(await readJsonBody(request));
|
|
88
|
+
try {
|
|
89
|
+
const result = await module.create({
|
|
90
|
+
tenantId: body.tenantId,
|
|
91
|
+
name: body.name,
|
|
92
|
+
principalId: body.principalId,
|
|
93
|
+
credentialHash: body.credentialHash,
|
|
94
|
+
idempotencyKey: body.idempotencyKey,
|
|
95
|
+
});
|
|
96
|
+
return sendJson(response, result.created ? 201 : 200, result.envelope);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const code = error.code;
|
|
100
|
+
if (error instanceof TenantConflictError ||
|
|
101
|
+
error.name === "TenantConflictError" ||
|
|
102
|
+
code === "conflict" ||
|
|
103
|
+
code === "tenant_conflict") {
|
|
104
|
+
return sendRejected(response, 409, "tenant_conflict", error instanceof Error ? error.message : "Tenant conflict");
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (segments.length === 4 && segments[3]) {
|
|
110
|
+
const id = segments[3];
|
|
111
|
+
if (method === "GET") {
|
|
112
|
+
const status = await module.status(id);
|
|
113
|
+
if (!status)
|
|
114
|
+
return sendOpaqueNotFound(response);
|
|
115
|
+
return sendJson(response, 200, status);
|
|
116
|
+
}
|
|
117
|
+
if (method === "DELETE") {
|
|
118
|
+
const activeWork = urlObj.searchParams.get("activeWork") ?? "refuse";
|
|
119
|
+
if (activeWork !== "refuse" &&
|
|
120
|
+
activeWork !== "drain" &&
|
|
121
|
+
activeWork !== "cancel") {
|
|
122
|
+
return sendJson(response, 400, {
|
|
123
|
+
status: "rejected",
|
|
124
|
+
code: "invalid_request",
|
|
125
|
+
message: "activeWork must be refuse, drain, or cancel",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await module.delete(id, activeWork);
|
|
130
|
+
response.writeHead(204);
|
|
131
|
+
response.end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
const code = error.code;
|
|
136
|
+
if (error instanceof TenantBusyError ||
|
|
137
|
+
error.name === "TenantBusyError" ||
|
|
138
|
+
code === "active_work" ||
|
|
139
|
+
code === "conflict") {
|
|
140
|
+
return sendRejected(response, 409, "active_work", error instanceof Error ? error.message : "Active work");
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// D12: /v1/admin/status and /v1/admin/host share one handler.
|
|
148
|
+
if ((segments[2] === "host" || segments[2] === "status") &&
|
|
149
|
+
segments.length === 3 &&
|
|
150
|
+
method === "GET") {
|
|
151
|
+
return sendJson(response, 200, await adminStatusBody());
|
|
152
|
+
}
|
|
153
|
+
if (segments[2] === "host" &&
|
|
154
|
+
segments.length === 4 &&
|
|
155
|
+
segments[3] === "shutdown" &&
|
|
156
|
+
method === "POST") {
|
|
157
|
+
sendJson(response, 200, { status: "shutting_down" });
|
|
158
|
+
void close();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
return sendRejected(response, 404, "not_found", "Route not found");
|
|
162
|
+
};
|
|
163
|
+
const handle = async (request, response) => {
|
|
164
|
+
const started = Date.now();
|
|
165
|
+
let tenantId;
|
|
166
|
+
let statusCode = 500;
|
|
167
|
+
try {
|
|
168
|
+
const urlObj = new URL(request.url ?? "/", "http://runtime.local");
|
|
169
|
+
const pathname = urlObj.pathname;
|
|
170
|
+
// D§11: Host, Origin, then Content-Type — before any other processing.
|
|
171
|
+
const hostHeader = headerValue(request.headers, "host");
|
|
172
|
+
if (!isAllowedRequestHost(hostHeader, {
|
|
173
|
+
port: listenPort,
|
|
174
|
+
host: config.host,
|
|
175
|
+
allowNonLoopback: config.allowNonLoopback,
|
|
176
|
+
})) {
|
|
177
|
+
sendRejected(response, 421, "host_rejected", "Host header is not an allowed loopback or configured address");
|
|
178
|
+
statusCode = 421;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (headerValue(request.headers, "origin") !== undefined) {
|
|
182
|
+
sendRejected(response, 403, "origin_rejected", "Browser Origin headers are not accepted");
|
|
183
|
+
statusCode = 403;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (requestHasBody(request) &&
|
|
187
|
+
!isJsonContentType(headerValue(request.headers, "content-type"))) {
|
|
188
|
+
sendRejected(response, 415, "unsupported_media_type", "Request bodies must use application/json");
|
|
189
|
+
statusCode = 415;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (pathname === "/health") {
|
|
193
|
+
sendJson(response, 200, {
|
|
194
|
+
status: "ok",
|
|
195
|
+
service: "nylorun-runtime",
|
|
196
|
+
version: RUNTIME_VERSION,
|
|
197
|
+
protocol: {
|
|
198
|
+
min: HOST_PROTOCOL.min,
|
|
199
|
+
max: HOST_PROTOCOL.max,
|
|
200
|
+
features: [...HOST_PROTOCOL.features],
|
|
201
|
+
},
|
|
202
|
+
coreVersion,
|
|
203
|
+
hostId: config.hostId,
|
|
204
|
+
pid,
|
|
205
|
+
});
|
|
206
|
+
statusCode = 200;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (pathname === "/ready") {
|
|
210
|
+
const listener = Boolean(server?.listening);
|
|
211
|
+
const discovery = module.started;
|
|
212
|
+
const ready = listener && discovery && !closing;
|
|
213
|
+
sendJson(response, ready ? 200 : 503, {
|
|
214
|
+
status: ready ? "ready" : "not_ready",
|
|
215
|
+
service: "nylorun-runtime",
|
|
216
|
+
checks: { listener, discovery },
|
|
217
|
+
});
|
|
218
|
+
statusCode = ready ? 200 : 503;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
222
|
+
const isAdmin = segments[0] === "v1" && segments[1] === "admin";
|
|
223
|
+
if (isAdmin) {
|
|
224
|
+
const protocolHeader = headerValue(request.headers, PROTOCOL_HEADER);
|
|
225
|
+
if (!protocolAccepted(protocolHeader)) {
|
|
226
|
+
sendProtocolRejected(response);
|
|
227
|
+
statusCode = 426;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!requireAdmin(request, response)) {
|
|
231
|
+
statusCode = 404;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
await handleAdmin(request, response, urlObj, segments);
|
|
235
|
+
statusCode = response.statusCode || 200;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// Tenant-scoped routes: header pattern → protocol → resolve → Tenant handle.
|
|
239
|
+
const tenantHeader = headerValue(request.headers, TENANT_HEADER);
|
|
240
|
+
if (tenantHeader === undefined || tenantHeader.trim() === "") {
|
|
241
|
+
sendJson(response, 400, {
|
|
242
|
+
status: "rejected",
|
|
243
|
+
code: "invalid_request",
|
|
244
|
+
message: `${TENANT_HEADER} header is required`,
|
|
245
|
+
});
|
|
246
|
+
statusCode = 400;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!isTenantId(tenantHeader)) {
|
|
250
|
+
sendJson(response, 400, {
|
|
251
|
+
status: "rejected",
|
|
252
|
+
code: "invalid_request",
|
|
253
|
+
message: `${TENANT_HEADER} header is malformed`,
|
|
254
|
+
});
|
|
255
|
+
statusCode = 400;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
tenantId = tenantHeader;
|
|
259
|
+
const protocolHeader = headerValue(request.headers, PROTOCOL_HEADER);
|
|
260
|
+
if (!protocolAccepted(protocolHeader)) {
|
|
261
|
+
sendProtocolRejected(response);
|
|
262
|
+
statusCode = 426;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const resolution = module.resolve(tenantId);
|
|
266
|
+
if (resolution.kind !== "open") {
|
|
267
|
+
if (resolution.kind === "quarantined") {
|
|
268
|
+
logger.warn("tenant_quarantined", {
|
|
269
|
+
tenantId,
|
|
270
|
+
code: resolution.quarantine.code,
|
|
271
|
+
repair: resolution.quarantine.repair,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
sendOpaqueNotFound(response);
|
|
275
|
+
statusCode = 404;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
await resolution.handle.handle(request, response, urlObj);
|
|
279
|
+
statusCode = response.statusCode || 200;
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
const status = error.status;
|
|
283
|
+
if (typeof status === "number" && status >= 400 && status < 600) {
|
|
284
|
+
sendJson(response, status, {
|
|
285
|
+
status: "rejected",
|
|
286
|
+
code: status === 400 ? "invalid_request" : "request_rejected",
|
|
287
|
+
message: error instanceof Error ? error.message : "Request rejected",
|
|
288
|
+
});
|
|
289
|
+
statusCode = status;
|
|
290
|
+
}
|
|
291
|
+
else if (error &&
|
|
292
|
+
typeof error === "object" &&
|
|
293
|
+
"name" in error &&
|
|
294
|
+
error.name === "ZodError") {
|
|
295
|
+
sendJson(response, 400, {
|
|
296
|
+
status: "rejected",
|
|
297
|
+
code: "invalid_request",
|
|
298
|
+
message: "Invalid request body",
|
|
299
|
+
});
|
|
300
|
+
statusCode = 400;
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
logger.error("request_failed", {
|
|
304
|
+
error: error instanceof Error ? error.message : String(error),
|
|
305
|
+
});
|
|
306
|
+
if (!response.headersSent) {
|
|
307
|
+
sendJson(response, 500, {
|
|
308
|
+
status: "rejected",
|
|
309
|
+
code: "internal_error",
|
|
310
|
+
message: "Internal error",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
statusCode = 500;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
if (pathnameIsLogged(request.url)) {
|
|
318
|
+
logger.info("request", {
|
|
319
|
+
status: statusCode,
|
|
320
|
+
tenantId,
|
|
321
|
+
durationMs: Date.now() - started,
|
|
322
|
+
path: redactRoutePath(new URL(request.url ?? "/", "http://runtime.local").pathname),
|
|
323
|
+
method: request.method,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
async function listen() {
|
|
329
|
+
if (server)
|
|
330
|
+
throw new Error("Already listening");
|
|
331
|
+
if (!config.allowNonLoopback && !isLoopbackHost(config.host)) {
|
|
332
|
+
throw new HostListenError(`Refusing to bind non-loopback host ${config.host} without allowNonLoopback`, EXIT_NON_LOOPBACK);
|
|
333
|
+
}
|
|
334
|
+
server = createServer((req, res) => {
|
|
335
|
+
void handle(req, res);
|
|
336
|
+
});
|
|
337
|
+
await new Promise((resolve, reject) => {
|
|
338
|
+
server.once("error", (error) => {
|
|
339
|
+
if (error.code === "EADDRINUSE") {
|
|
340
|
+
reject(new HostListenError(`Port ${config.port} on ${config.host} is already in use`, EXIT_PORT_IN_USE, error));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
reject(error);
|
|
344
|
+
});
|
|
345
|
+
server.listen(config.port, config.host, resolve);
|
|
346
|
+
});
|
|
347
|
+
const address = server.address();
|
|
348
|
+
listenPort =
|
|
349
|
+
typeof address === "object" && address ? address.port : config.port;
|
|
350
|
+
url = `http://${config.host}:${listenPort}`;
|
|
351
|
+
await module.start();
|
|
352
|
+
logger.info("host_listening", {
|
|
353
|
+
hostId: config.hostId,
|
|
354
|
+
url,
|
|
355
|
+
pid,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
async function close() {
|
|
359
|
+
if (closePromise)
|
|
360
|
+
return closePromise;
|
|
361
|
+
closePromise = (async () => {
|
|
362
|
+
closing = true;
|
|
363
|
+
logger.info("host_shutdown", { hostId: config.hostId });
|
|
364
|
+
if (server) {
|
|
365
|
+
await new Promise((resolve) => {
|
|
366
|
+
server.close(() => resolve());
|
|
367
|
+
server.closeIdleConnections?.();
|
|
368
|
+
});
|
|
369
|
+
server = undefined;
|
|
370
|
+
}
|
|
371
|
+
await module.close();
|
|
372
|
+
if (existsSync(paths.state)) {
|
|
373
|
+
try {
|
|
374
|
+
unlinkSync(paths.state);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
/* best-effort */
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
})();
|
|
381
|
+
return closePromise;
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
listen,
|
|
385
|
+
close,
|
|
386
|
+
get url() {
|
|
387
|
+
return url;
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function pathnameIsLogged(rawUrl) {
|
|
392
|
+
if (!rawUrl)
|
|
393
|
+
return true;
|
|
394
|
+
const pathname = new URL(rawUrl, "http://runtime.local").pathname;
|
|
395
|
+
return pathname !== "/health" && pathname !== "/ready";
|
|
396
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HostConfigFile } from "./config.js";
|
|
2
|
+
import type { HostPaths } from "../tenant/paths.js";
|
|
3
|
+
import type { TenantPaths } from "../tenant/types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Allowlisted ambient keys for Host and Tenant child processes.
|
|
6
|
+
* `NODE_OPTIONS` is never copied.
|
|
7
|
+
*/
|
|
8
|
+
export declare function baselineEnvironment(env: NodeJS.ProcessEnv | Readonly<Record<string, string | undefined>>): Record<string, string>;
|
|
9
|
+
export type HostProcessPathInputs = Pick<HostPaths, "root" | "home" | "tmp">;
|
|
10
|
+
/** Environment for the Runtime Host process itself (CLI spawn / `runtime run`). */
|
|
11
|
+
export declare function hostProcessEnvironment(baseline: Readonly<Record<string, string>>, hostConfig: HostConfigFile, paths: HostProcessPathInputs): Record<string, string>;
|
|
12
|
+
/** Environment for Tenant-owned children (MCP stdio, sandboxes). */
|
|
13
|
+
export declare function tenantChildEnvironment(baseline: Readonly<Record<string, string>>, hostConfig: HostConfigFile, tenantPaths: TenantPaths): Record<string, string>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const BASELINE_KEYS = new Set(["PATH", "LANG", "TZ"]);
|
|
2
|
+
function isBaselineKey(key) {
|
|
3
|
+
return BASELINE_KEYS.has(key) || key.startsWith("LC_");
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Allowlisted ambient keys for Host and Tenant child processes.
|
|
7
|
+
* `NODE_OPTIONS` is never copied.
|
|
8
|
+
*/
|
|
9
|
+
export function baselineEnvironment(env) {
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const [key, value] of Object.entries(env)) {
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
continue;
|
|
14
|
+
if (key === "NODE_OPTIONS")
|
|
15
|
+
continue;
|
|
16
|
+
if (!isBaselineKey(key))
|
|
17
|
+
continue;
|
|
18
|
+
out[key] = value;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
function proxyEnv(hostConfig) {
|
|
23
|
+
const out = {};
|
|
24
|
+
const proxy = hostConfig.proxy;
|
|
25
|
+
if (!proxy)
|
|
26
|
+
return out;
|
|
27
|
+
if (proxy.httpsProxy)
|
|
28
|
+
out.HTTPS_PROXY = proxy.httpsProxy;
|
|
29
|
+
if (proxy.noProxy)
|
|
30
|
+
out.NO_PROXY = proxy.noProxy;
|
|
31
|
+
if (proxy.nodeExtraCaCerts)
|
|
32
|
+
out.NODE_EXTRA_CA_CERTS = proxy.nodeExtraCaCerts;
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** Environment for the Runtime Host process itself (CLI spawn / `runtime run`). */
|
|
36
|
+
export function hostProcessEnvironment(baseline, hostConfig, paths) {
|
|
37
|
+
return {
|
|
38
|
+
...baseline,
|
|
39
|
+
...proxyEnv(hostConfig),
|
|
40
|
+
HOME: paths.home,
|
|
41
|
+
TMPDIR: paths.tmp,
|
|
42
|
+
NYLORUN_HOME: paths.root,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Environment for Tenant-owned children (MCP stdio, sandboxes). */
|
|
46
|
+
export function tenantChildEnvironment(baseline, hostConfig, tenantPaths) {
|
|
47
|
+
return {
|
|
48
|
+
...baseline,
|
|
49
|
+
...proxyEnv(hostConfig),
|
|
50
|
+
HOME: tenantPaths.home,
|
|
51
|
+
TMPDIR: tenantPaths.tmp,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { type ErrorCode, type ProtocolRange } from "@nylorun/core/compatibility";
|
|
3
|
+
export declare const OPAQUE_NOT_FOUND: {
|
|
4
|
+
status: "rejected";
|
|
5
|
+
code: "not_found";
|
|
6
|
+
message: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const EXIT_PORT_IN_USE = 98;
|
|
9
|
+
export declare const EXIT_NON_LOOPBACK = 99;
|
|
10
|
+
export declare class HostListenError extends Error {
|
|
11
|
+
readonly exitCode: number;
|
|
12
|
+
readonly cause?: unknown;
|
|
13
|
+
constructor(message: string, exitCode: number, cause?: unknown);
|
|
14
|
+
}
|
|
15
|
+
export declare function sendJson(response: ServerResponse, status: number, body: unknown): void;
|
|
16
|
+
export declare function sendRejected(response: ServerResponse, status: number, code: ErrorCode, message: string, details?: unknown): void;
|
|
17
|
+
export declare function sendOpaqueNotFound(response: ServerResponse): void;
|
|
18
|
+
export declare function sendProtocolRejected(response: ServerResponse, protocol?: ProtocolRange): void;
|
|
19
|
+
/**
|
|
20
|
+
* D§11 Host allowlist: loopback forms with the listening port, plus the
|
|
21
|
+
* configured host when `allowNonLoopback` is set.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isAllowedRequestHost(hostHeader: string | undefined, options: {
|
|
24
|
+
port: number;
|
|
25
|
+
host: string;
|
|
26
|
+
allowNonLoopback?: boolean;
|
|
27
|
+
}): boolean;
|
|
28
|
+
export declare function isJsonContentType(value: string | undefined): boolean;
|
|
29
|
+
/** True when the request carries a body (Content-Length > 0 or chunked). */
|
|
30
|
+
export declare function requestHasBody(request: IncomingMessage): boolean;
|
|
31
|
+
/** Redact path parameters after `/v1/sessions/` for Host logs. */
|
|
32
|
+
export declare function redactRoutePath(pathname: string): string;
|
|
33
|
+
export declare function readBearer(authorization: string | undefined): string | undefined;
|
|
34
|
+
export declare function readJsonBody(request: import("node:http").IncomingMessage, limit?: number): Promise<unknown>;
|
|
35
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { HOST_PROTOCOL, } from "@nylorun/core/compatibility";
|
|
2
|
+
export const OPAQUE_NOT_FOUND = {
|
|
3
|
+
status: "rejected",
|
|
4
|
+
code: "not_found",
|
|
5
|
+
message: "Not found",
|
|
6
|
+
};
|
|
7
|
+
export const EXIT_PORT_IN_USE = 98;
|
|
8
|
+
export const EXIT_NON_LOOPBACK = 99;
|
|
9
|
+
export class HostListenError extends Error {
|
|
10
|
+
exitCode;
|
|
11
|
+
cause;
|
|
12
|
+
constructor(message, exitCode, cause) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.exitCode = exitCode;
|
|
15
|
+
this.cause = cause;
|
|
16
|
+
this.name = "HostListenError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function sendJson(response, status, body) {
|
|
20
|
+
const payload = JSON.stringify(body);
|
|
21
|
+
// No Access-Control-* headers (D§11).
|
|
22
|
+
response.writeHead(status, {
|
|
23
|
+
"content-type": "application/json",
|
|
24
|
+
"content-length": Buffer.byteLength(payload),
|
|
25
|
+
});
|
|
26
|
+
response.end(payload);
|
|
27
|
+
}
|
|
28
|
+
export function sendRejected(response, status, code, message, details) {
|
|
29
|
+
const body = { status: "rejected", code, message };
|
|
30
|
+
if (details !== undefined)
|
|
31
|
+
body.details = details;
|
|
32
|
+
sendJson(response, status, body);
|
|
33
|
+
}
|
|
34
|
+
export function sendOpaqueNotFound(response) {
|
|
35
|
+
sendJson(response, 404, OPAQUE_NOT_FOUND);
|
|
36
|
+
}
|
|
37
|
+
export function sendProtocolRejected(response, protocol = HOST_PROTOCOL) {
|
|
38
|
+
sendJson(response, 426, {
|
|
39
|
+
status: "rejected",
|
|
40
|
+
code: "protocol_unsupported",
|
|
41
|
+
protocol: {
|
|
42
|
+
min: protocol.min,
|
|
43
|
+
max: protocol.max,
|
|
44
|
+
features: [...protocol.features],
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* D§11 Host allowlist: loopback forms with the listening port, plus the
|
|
50
|
+
* configured host when `allowNonLoopback` is set.
|
|
51
|
+
*/
|
|
52
|
+
export function isAllowedRequestHost(hostHeader, options) {
|
|
53
|
+
if (hostHeader === undefined)
|
|
54
|
+
return false;
|
|
55
|
+
const normalized = hostHeader.trim().toLowerCase();
|
|
56
|
+
const port = String(options.port);
|
|
57
|
+
const allowed = new Set([
|
|
58
|
+
`127.0.0.1:${port}`,
|
|
59
|
+
`localhost:${port}`,
|
|
60
|
+
`[::1]:${port}`,
|
|
61
|
+
]);
|
|
62
|
+
if (options.allowNonLoopback) {
|
|
63
|
+
const configured = options.host.trim().toLowerCase();
|
|
64
|
+
if (configured.includes(":") && !configured.startsWith("[")) {
|
|
65
|
+
allowed.add(`[${configured}]:${port}`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
allowed.add(`${configured}:${port}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return allowed.has(normalized);
|
|
72
|
+
}
|
|
73
|
+
export function isJsonContentType(value) {
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
return false;
|
|
76
|
+
const media = value.split(";")[0].trim().toLowerCase();
|
|
77
|
+
return media === "application/json";
|
|
78
|
+
}
|
|
79
|
+
/** True when the request carries a body (Content-Length > 0 or chunked). */
|
|
80
|
+
export function requestHasBody(request) {
|
|
81
|
+
const te = request.headers["transfer-encoding"];
|
|
82
|
+
const transfer = Array.isArray(te) ? te.join(",") : te;
|
|
83
|
+
if (transfer && transfer.toLowerCase() !== "identity")
|
|
84
|
+
return true;
|
|
85
|
+
const cl = request.headers["content-length"];
|
|
86
|
+
const raw = Array.isArray(cl) ? cl[0] : cl;
|
|
87
|
+
if (raw === undefined)
|
|
88
|
+
return false;
|
|
89
|
+
const n = Number(raw);
|
|
90
|
+
return Number.isFinite(n) && n > 0;
|
|
91
|
+
}
|
|
92
|
+
/** Redact path parameters after `/v1/sessions/` for Host logs. */
|
|
93
|
+
export function redactRoutePath(pathname) {
|
|
94
|
+
const parts = pathname.split("/");
|
|
95
|
+
// ["", "v1", "sessions", "<id>", ...]
|
|
96
|
+
if (parts[1] === "v1" && parts[2] === "sessions" && parts.length > 3) {
|
|
97
|
+
return `/v1/sessions/:id${parts.length > 4 ? "/…" : ""}`;
|
|
98
|
+
}
|
|
99
|
+
return pathname;
|
|
100
|
+
}
|
|
101
|
+
export function readBearer(authorization) {
|
|
102
|
+
if (!authorization)
|
|
103
|
+
return undefined;
|
|
104
|
+
const match = /^Bearer\s+(\S+)/i.exec(authorization.trim());
|
|
105
|
+
return match?.[1];
|
|
106
|
+
}
|
|
107
|
+
export async function readJsonBody(request, limit = 1024 * 1024) {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
let size = 0;
|
|
110
|
+
for await (const chunk of request) {
|
|
111
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
112
|
+
size += buf.length;
|
|
113
|
+
if (size > limit) {
|
|
114
|
+
const error = new Error("Request too large");
|
|
115
|
+
error.status = 413;
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
chunks.push(buf);
|
|
119
|
+
}
|
|
120
|
+
if (chunks.length === 0)
|
|
121
|
+
return undefined;
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
const error = new Error("Invalid JSON");
|
|
127
|
+
error.status = 400;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function isLoopbackHost(host) {
|
|
132
|
+
const normalized = host.trim().toLowerCase();
|
|
133
|
+
return (normalized === "127.0.0.1" ||
|
|
134
|
+
normalized === "::1" ||
|
|
135
|
+
normalized === "localhost");
|
|
136
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { HostConfigFile, HostCredentialsFile, HostStateFile, } from "./config.js";
|
|
2
|
+
export { baselineEnvironment, hostProcessEnvironment, tenantChildEnvironment, } from "./environment.js";
|
|
3
|
+
export { createHost, adminKeyMatches, type CreateHostOptions, type HostServer } from "./create-host.js";
|
|
4
|
+
export { createHostLogger } from "./logger.js";
|
|
5
|
+
export { configForFactory } from "./config-for.js";
|
|
6
|
+
export { EXIT_PORT_IN_USE, EXIT_NON_LOOPBACK, HostListenError, OPAQUE_NOT_FOUND, } from "./http.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { baselineEnvironment, hostProcessEnvironment, tenantChildEnvironment, } from "./environment.js";
|
|
2
|
+
export { createHost, adminKeyMatches } from "./create-host.js";
|
|
3
|
+
export { createHostLogger } from "./logger.js";
|
|
4
|
+
export { configForFactory } from "./config-for.js";
|
|
5
|
+
export { EXIT_PORT_IN_USE, EXIT_NON_LOOPBACK, HostListenError, OPAQUE_NOT_FOUND, } from "./http.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Host logger: JSON lines to stdout (CLI redirects to `runtime.log`). */
|
|
2
|
+
export function createHostLogger(write = (line) => {
|
|
3
|
+
process.stdout.write(`${line}\n`);
|
|
4
|
+
}) {
|
|
5
|
+
const emit = (level, message, fields) => {
|
|
6
|
+
write(JSON.stringify({
|
|
7
|
+
level,
|
|
8
|
+
message,
|
|
9
|
+
time: new Date().toISOString(),
|
|
10
|
+
...fields,
|
|
11
|
+
}));
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
info: (message, fields) => emit("info", message, fields),
|
|
15
|
+
warn: (message, fields) => emit("warn", message, fields),
|
|
16
|
+
error: (message, fields) => emit("error", message, fields),
|
|
17
|
+
};
|
|
18
|
+
}
|