@agent-vm/gateway-lifecycle 0.0.115
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/LICENSE +21 -0
- package/dist/index.d.ts +1121 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1323 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
import { v7, validate, version } from "uuid";
|
|
2
|
+
//#region src/gateway-runtime-contract.ts
|
|
3
|
+
const gatewayTypeValues = [
|
|
4
|
+
"openclaw",
|
|
5
|
+
"hermes",
|
|
6
|
+
"worker"
|
|
7
|
+
];
|
|
8
|
+
function buildGatewaySessionLabel(projectNamespace, zoneId) {
|
|
9
|
+
return `${projectNamespace}:${zoneId}:gateway`;
|
|
10
|
+
}
|
|
11
|
+
function buildToolSessionLabel(projectNamespace, zoneId, tcpSlot) {
|
|
12
|
+
return `${projectNamespace}:${zoneId}:tool:${tcpSlot}`;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/gateway-lifecycle.ts
|
|
16
|
+
const gatewayToolPortalTelemetryServiceName = "agent-vm-tool-portal";
|
|
17
|
+
const gatewayFrameworkTelemetryServiceNames = Object.freeze({
|
|
18
|
+
hermes: "agent-vm-hermes",
|
|
19
|
+
openclaw: "agent-vm-openclaw"
|
|
20
|
+
});
|
|
21
|
+
const gatewayTelemetrySourcePolicy = Object.freeze({
|
|
22
|
+
admitBaggage: false,
|
|
23
|
+
captureContent: false
|
|
24
|
+
});
|
|
25
|
+
const gatewayTelemetryAdmissionLimits = Object.freeze({
|
|
26
|
+
maxExportBatchRecords: 64,
|
|
27
|
+
maxQueuedRecordsPerSignal: 256,
|
|
28
|
+
maxRecordBytes: 65536
|
|
29
|
+
});
|
|
30
|
+
function createGatewayTelemetryProducerSafetyContract() {
|
|
31
|
+
if (gatewayTelemetryAdmissionLimits.maxExportBatchRecords > gatewayTelemetryAdmissionLimits.maxQueuedRecordsPerSignal) throw new Error("Gateway telemetry maxExportBatchRecords must not exceed maxQueuedRecordsPerSignal.");
|
|
32
|
+
return {
|
|
33
|
+
admissionLimits: { ...gatewayTelemetryAdmissionLimits },
|
|
34
|
+
sourcePolicy: { ...gatewayTelemetrySourcePolicy }
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/audience.ts
|
|
39
|
+
const vmAudienceValues = [
|
|
40
|
+
"gateway",
|
|
41
|
+
"tool-vm",
|
|
42
|
+
"both"
|
|
43
|
+
];
|
|
44
|
+
const controllerVmHost = "controller.vm.host";
|
|
45
|
+
function targetsAudience(configAudience, runtimeAudience) {
|
|
46
|
+
return configAudience === runtimeAudience || configAudience === "both";
|
|
47
|
+
}
|
|
48
|
+
function egressHostsForAudience(egressHosts, runtimeAudience) {
|
|
49
|
+
return egressHosts.filter((egressHost) => targetsAudience(egressHost.audience, runtimeAudience)).map((egressHost) => egressHost.host);
|
|
50
|
+
}
|
|
51
|
+
function gatewayVmAllowedHosts(egressHosts) {
|
|
52
|
+
return Array.from(new Set(egressHostsForAudience(egressHosts, "gateway").filter((host) => host !== controllerVmHost)));
|
|
53
|
+
}
|
|
54
|
+
function workerVmAllowedHosts(egressHosts) {
|
|
55
|
+
return Array.from(new Set(egressHostsForAudience(egressHosts, "gateway").filter((host) => host !== controllerVmHost)));
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/websocket-upgrade-policy.ts
|
|
59
|
+
function escapeRegExpLiteral(value) {
|
|
60
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
61
|
+
}
|
|
62
|
+
function hostMatchesPattern(host, pattern) {
|
|
63
|
+
const normalizedPattern = pattern.trim().toLowerCase();
|
|
64
|
+
if (normalizedPattern === "") return false;
|
|
65
|
+
if (normalizedPattern === "*") return true;
|
|
66
|
+
return new RegExp(`^${normalizedPattern.split("*").map(escapeRegExpLiteral).join(".*")}$`, "iu").test(host.toLowerCase());
|
|
67
|
+
}
|
|
68
|
+
function isWebSocketUpgradeRequest(request) {
|
|
69
|
+
const upgrade = request.headers.get("upgrade")?.toLowerCase() ?? "";
|
|
70
|
+
const connection = request.headers.get("connection")?.toLowerCase() ?? "";
|
|
71
|
+
return upgrade === "websocket" || connection.split(",").map((token) => token.trim()).includes("upgrade") || request.headers.has("sec-websocket-key") || request.headers.has("sec-websocket-version");
|
|
72
|
+
}
|
|
73
|
+
function requestSchemeForRule(url) {
|
|
74
|
+
if (url.protocol === "http:") return "ws";
|
|
75
|
+
if (url.protocol === "https:") return "wss";
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
function requestPortForScheme(url, scheme) {
|
|
79
|
+
if (url.port.length > 0) return Number.parseInt(url.port, 10);
|
|
80
|
+
return scheme === "wss" ? 443 : 80;
|
|
81
|
+
}
|
|
82
|
+
function defaultPortForScheme(scheme) {
|
|
83
|
+
return scheme === "wss" ? 443 : 80;
|
|
84
|
+
}
|
|
85
|
+
function websocketRuleMatchesRequest(rule, request) {
|
|
86
|
+
const url = new URL(request.url);
|
|
87
|
+
const requestScheme = requestSchemeForRule(url);
|
|
88
|
+
if (requestScheme !== rule.scheme) return false;
|
|
89
|
+
if (!hostMatchesPattern(url.hostname, rule.host)) return false;
|
|
90
|
+
if (requestPortForScheme(url, requestScheme) !== (rule.port ?? defaultPortForScheme(rule.scheme))) return false;
|
|
91
|
+
if (rule.path !== void 0 && url.pathname !== rule.path) return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
function websocketUpgradesForAudience(rules, runtimeAudience) {
|
|
95
|
+
return (rules ?? []).filter((rule) => targetsAudience(rule.audience, runtimeAudience));
|
|
96
|
+
}
|
|
97
|
+
function createWebSocketUpgradeRequestGuard(options) {
|
|
98
|
+
const runtimeRules = websocketUpgradesForAudience(options.rules, options.runtimeAudience);
|
|
99
|
+
return async (request) => {
|
|
100
|
+
if (!isWebSocketUpgradeRequest(request)) return;
|
|
101
|
+
if (runtimeRules.some((rule) => websocketRuleMatchesRequest(rule, request))) return;
|
|
102
|
+
return new Response("WebSocket upgrade blocked by agent-vm policy", {
|
|
103
|
+
status: 403,
|
|
104
|
+
statusText: "Forbidden"
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/gateway-control-private-environment.ts
|
|
110
|
+
const GATEWAY_CONTROL_CALLER_CONTEXT_PROOF_KEY_ENV = "AGENT_VM_GATEWAY_CONTROL_CALLER_CONTEXT_PROOF_KEY";
|
|
111
|
+
const GATEWAY_CONTROL_CALLER_CONTEXT_AGENT_AUTHORITY_KEYS_ENV = "AGENT_VM_GATEWAY_CONTROL_CALLER_CONTEXT_AGENT_AUTHORITY_KEYS";
|
|
112
|
+
const GATEWAY_CONTROL_PRIVATE_ENVIRONMENT_NAMES = [GATEWAY_CONTROL_CALLER_CONTEXT_AGENT_AUTHORITY_KEYS_ENV, GATEWAY_CONTROL_CALLER_CONTEXT_PROOF_KEY_ENV];
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/health/controller-request-policy.ts
|
|
115
|
+
const gatewayInternalControllerRequestOperations = [
|
|
116
|
+
"workspace-git-push",
|
|
117
|
+
"lease-create",
|
|
118
|
+
"lease-get",
|
|
119
|
+
"lease-peek",
|
|
120
|
+
"lease-renew",
|
|
121
|
+
"lease-release",
|
|
122
|
+
"lease-use-start",
|
|
123
|
+
"lease-heartbeat",
|
|
124
|
+
"lease-use-end"
|
|
125
|
+
];
|
|
126
|
+
const dedicatedControllerRequestHealthEventOperationSet = new Set(["lease-heartbeat", "lease-renew"]);
|
|
127
|
+
function isGenericControllerRequestEventOperation(operation) {
|
|
128
|
+
return !dedicatedControllerRequestHealthEventOperationSet.has(operation);
|
|
129
|
+
}
|
|
130
|
+
const genericControllerRequestEventOperations = [...gatewayInternalControllerRequestOperations].filter(isGenericControllerRequestEventOperation);
|
|
131
|
+
const externalControllerRoutes = [
|
|
132
|
+
"GET /controller-status",
|
|
133
|
+
"GET /zones/:zoneId/status",
|
|
134
|
+
"GET /zones/:zoneId/health",
|
|
135
|
+
"GET /zones/:zoneId/logs",
|
|
136
|
+
"POST /zones/:zoneId/credentials/refresh",
|
|
137
|
+
"POST /zones/:zoneId/destroy",
|
|
138
|
+
"POST /zones/:zoneId/upgrade",
|
|
139
|
+
"GET /zones/:zoneId/tasks/:taskId",
|
|
140
|
+
"POST /zones/:zoneId/worker-tasks",
|
|
141
|
+
"POST /zones/:zoneId/tasks/:taskId/close",
|
|
142
|
+
"POST /zones/:zoneId/enable-ssh",
|
|
143
|
+
"POST /zones/:zoneId/execute-command",
|
|
144
|
+
"POST /stop-controller"
|
|
145
|
+
];
|
|
146
|
+
var ControllerRequestPolicyTransportError = class extends Error {
|
|
147
|
+
code;
|
|
148
|
+
operation;
|
|
149
|
+
constructor(options) {
|
|
150
|
+
const causeMessage = options.cause instanceof Error ? options.cause.message : String(options.cause);
|
|
151
|
+
super(`${options.operation} ${options.code}: ${causeMessage}`, { cause: options.cause });
|
|
152
|
+
this.code = options.code;
|
|
153
|
+
this.operation = options.operation;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
async function drainControllerResponseBody(response) {
|
|
157
|
+
if (response.bodyUsed) return;
|
|
158
|
+
await response.text().catch(() => void 0);
|
|
159
|
+
}
|
|
160
|
+
const controllerRequestPolicies = {
|
|
161
|
+
"workspace-git-push": {
|
|
162
|
+
idempotency: "unsafe-mutation",
|
|
163
|
+
maxAttempts: 1,
|
|
164
|
+
retryBaseDelayMs: 0,
|
|
165
|
+
retryEnabled: false,
|
|
166
|
+
retryStatuses: [],
|
|
167
|
+
timeoutMs: 12e4
|
|
168
|
+
},
|
|
169
|
+
"lease-create": {
|
|
170
|
+
idempotency: "unsafe-mutation",
|
|
171
|
+
maxAttempts: 1,
|
|
172
|
+
retryBaseDelayMs: 0,
|
|
173
|
+
retryEnabled: false,
|
|
174
|
+
retryStatuses: [],
|
|
175
|
+
timeoutMs: 18e4
|
|
176
|
+
},
|
|
177
|
+
"lease-get": {
|
|
178
|
+
idempotency: "read",
|
|
179
|
+
maxAttempts: 2,
|
|
180
|
+
retryBaseDelayMs: 250,
|
|
181
|
+
retryEnabled: true,
|
|
182
|
+
retryStatuses: [503, 504],
|
|
183
|
+
timeoutMs: 5e3
|
|
184
|
+
},
|
|
185
|
+
"lease-peek": {
|
|
186
|
+
idempotency: "read",
|
|
187
|
+
maxAttempts: 2,
|
|
188
|
+
retryBaseDelayMs: 250,
|
|
189
|
+
retryEnabled: true,
|
|
190
|
+
retryStatuses: [503, 504],
|
|
191
|
+
timeoutMs: 5e3
|
|
192
|
+
},
|
|
193
|
+
"lease-renew": {
|
|
194
|
+
idempotency: "safe-mutation",
|
|
195
|
+
maxAttempts: 3,
|
|
196
|
+
retryBaseDelayMs: 250,
|
|
197
|
+
retryEnabled: true,
|
|
198
|
+
retryStatuses: [
|
|
199
|
+
429,
|
|
200
|
+
503,
|
|
201
|
+
504
|
|
202
|
+
],
|
|
203
|
+
timeoutMs: 1e4
|
|
204
|
+
},
|
|
205
|
+
"lease-release": {
|
|
206
|
+
idempotency: "safe-mutation",
|
|
207
|
+
maxAttempts: 2,
|
|
208
|
+
retryBaseDelayMs: 250,
|
|
209
|
+
retryEnabled: true,
|
|
210
|
+
retryStatuses: [503, 504],
|
|
211
|
+
timeoutMs: 5e3
|
|
212
|
+
},
|
|
213
|
+
"lease-use-start": {
|
|
214
|
+
idempotency: "safe-mutation",
|
|
215
|
+
maxAttempts: 2,
|
|
216
|
+
retryBaseDelayMs: 250,
|
|
217
|
+
retryEnabled: true,
|
|
218
|
+
retryStatuses: [
|
|
219
|
+
429,
|
|
220
|
+
503,
|
|
221
|
+
504
|
|
222
|
+
],
|
|
223
|
+
timeoutMs: 1e4
|
|
224
|
+
},
|
|
225
|
+
"lease-heartbeat": {
|
|
226
|
+
idempotency: "safe-mutation",
|
|
227
|
+
maxAttempts: 2,
|
|
228
|
+
retryBaseDelayMs: 250,
|
|
229
|
+
retryEnabled: true,
|
|
230
|
+
retryStatuses: [
|
|
231
|
+
429,
|
|
232
|
+
503,
|
|
233
|
+
504
|
|
234
|
+
],
|
|
235
|
+
timeoutMs: 5e3
|
|
236
|
+
},
|
|
237
|
+
"lease-use-end": {
|
|
238
|
+
idempotency: "safe-mutation",
|
|
239
|
+
maxAttempts: 2,
|
|
240
|
+
retryBaseDelayMs: 250,
|
|
241
|
+
retryEnabled: true,
|
|
242
|
+
retryStatuses: [503, 504],
|
|
243
|
+
timeoutMs: 5e3
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/health/agent-vm-health.ts
|
|
248
|
+
const agentVmHealthEventKinds = [
|
|
249
|
+
"gateway-service-health",
|
|
250
|
+
"gateway-control-session",
|
|
251
|
+
"controller-request",
|
|
252
|
+
"lease-renew",
|
|
253
|
+
"lease-heartbeat",
|
|
254
|
+
"caller-context-rejection",
|
|
255
|
+
"tool-vm-ssh",
|
|
256
|
+
"gateway-plugin-health",
|
|
257
|
+
"agent-channel-provider-health",
|
|
258
|
+
"gateway-recovery",
|
|
259
|
+
"gateway-recovery-suspended"
|
|
260
|
+
];
|
|
261
|
+
const agentVmHealthResultKinds = [
|
|
262
|
+
"ok",
|
|
263
|
+
"failed",
|
|
264
|
+
"timeout",
|
|
265
|
+
"stale"
|
|
266
|
+
];
|
|
267
|
+
const toolVmLeaseLifecycleEventRoles = ["plugin_observation", "controller_final"];
|
|
268
|
+
const toolVmLeaseLifecycleTransitions = [
|
|
269
|
+
"current_to_stale",
|
|
270
|
+
"current_to_retired",
|
|
271
|
+
"deprecated_to_reacquired",
|
|
272
|
+
"deprecated_to_retired",
|
|
273
|
+
"stale_to_reacquired",
|
|
274
|
+
"stale_to_retired",
|
|
275
|
+
"retired_rejected"
|
|
276
|
+
];
|
|
277
|
+
const toolVmLeaseReacquiredLifecycleTransitions = ["deprecated_to_reacquired", "stale_to_reacquired"];
|
|
278
|
+
const toolVmLeaseCallerContextStates = [
|
|
279
|
+
"ok",
|
|
280
|
+
"absent",
|
|
281
|
+
"stale",
|
|
282
|
+
"session_mismatch",
|
|
283
|
+
"not_applicable"
|
|
284
|
+
];
|
|
285
|
+
const toolVmLeaseRejectionReasons = [
|
|
286
|
+
"caller_context_absent",
|
|
287
|
+
"caller_context_session_mismatch",
|
|
288
|
+
"caller_context_stale",
|
|
289
|
+
"lease_absent",
|
|
290
|
+
"lease_authority_absent",
|
|
291
|
+
"lease_force_released",
|
|
292
|
+
"lease_generation_stale",
|
|
293
|
+
"lease_reacquire_required",
|
|
294
|
+
"lease_releasing",
|
|
295
|
+
"lease_retired",
|
|
296
|
+
"lease_use_tombstoned",
|
|
297
|
+
"ownership_denied",
|
|
298
|
+
"runtime_not_ready"
|
|
299
|
+
];
|
|
300
|
+
const agentChannelProviderHealthKinds = [
|
|
301
|
+
"healthy",
|
|
302
|
+
"transitioning",
|
|
303
|
+
"unhealthy-recoverable",
|
|
304
|
+
"unhealthy-unrecoverable"
|
|
305
|
+
];
|
|
306
|
+
const agentChannelProviderHealthDetailKeys = [
|
|
307
|
+
"closeCode",
|
|
308
|
+
"providerType",
|
|
309
|
+
"reconnectAttempt",
|
|
310
|
+
"reconnecting",
|
|
311
|
+
"sleepResumeSuspected",
|
|
312
|
+
"statusCode"
|
|
313
|
+
];
|
|
314
|
+
const gatewayRecoveryHealthReasons = [
|
|
315
|
+
"agent-channel-provider-unhealthy",
|
|
316
|
+
"gateway-control-session-unhealthy",
|
|
317
|
+
"gateway-service-unhealthy"
|
|
318
|
+
];
|
|
319
|
+
const gatewayControlSessionHealthOperations = [
|
|
320
|
+
"control-session-hello",
|
|
321
|
+
"control-session-heartbeat",
|
|
322
|
+
"control-session-disconnect",
|
|
323
|
+
"control-session-reconnect"
|
|
324
|
+
];
|
|
325
|
+
const zoneHealthStateKinds = [
|
|
326
|
+
"unknown",
|
|
327
|
+
"ok",
|
|
328
|
+
"stale",
|
|
329
|
+
"failed"
|
|
330
|
+
];
|
|
331
|
+
const zoneHealthIssueKinds = [
|
|
332
|
+
"gateway-service-unhealthy",
|
|
333
|
+
"gateway-control-session-unhealthy",
|
|
334
|
+
"controller-request-failing",
|
|
335
|
+
"lease-heartbeat-failing",
|
|
336
|
+
"lease-renew-failing",
|
|
337
|
+
"tool-vm-ssh-failing",
|
|
338
|
+
"gateway-plugin-unhealthy",
|
|
339
|
+
"agent-channel-provider-unhealthy",
|
|
340
|
+
"gateway-recovery-failed",
|
|
341
|
+
"gateway-recovery-suspended",
|
|
342
|
+
"health-event-stale"
|
|
343
|
+
];
|
|
344
|
+
function isRecord(value) {
|
|
345
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
346
|
+
}
|
|
347
|
+
function isOneOf(values, value) {
|
|
348
|
+
return typeof value === "string" && values.includes(value);
|
|
349
|
+
}
|
|
350
|
+
function isNonNegativeFiniteNumber(value) {
|
|
351
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
352
|
+
}
|
|
353
|
+
function hasBaseEventFields(record) {
|
|
354
|
+
return isNonNegativeFiniteNumber(record.observedAtMs) && isOneOf(agentVmHealthResultKinds, record.result) && typeof record.zoneId === "string" && record.zoneId.length > 0;
|
|
355
|
+
}
|
|
356
|
+
function optionalString(value) {
|
|
357
|
+
return value === void 0 || typeof value === "string";
|
|
358
|
+
}
|
|
359
|
+
function optionalNonEmptyString(value) {
|
|
360
|
+
return value === void 0 || typeof value === "string" && value.length > 0;
|
|
361
|
+
}
|
|
362
|
+
function optionalOneOf(values, value) {
|
|
363
|
+
return value === void 0 || isOneOf(values, value);
|
|
364
|
+
}
|
|
365
|
+
function isToolVmLeaseReacquiredTransition(value) {
|
|
366
|
+
return isOneOf(toolVmLeaseReacquiredLifecycleTransitions, value);
|
|
367
|
+
}
|
|
368
|
+
function optionalStatusCode(value) {
|
|
369
|
+
return value === void 0 || Number.isInteger(value);
|
|
370
|
+
}
|
|
371
|
+
function optionalNonNegativeInteger(value) {
|
|
372
|
+
return value === void 0 || Number.isInteger(value) && Number(value) >= 0;
|
|
373
|
+
}
|
|
374
|
+
function isNonNegativeInteger(value) {
|
|
375
|
+
return Number.isInteger(value) && Number(value) >= 0;
|
|
376
|
+
}
|
|
377
|
+
function isPositiveInteger(value) {
|
|
378
|
+
return Number.isInteger(value) && Number(value) > 0;
|
|
379
|
+
}
|
|
380
|
+
function isRedactedHealthDetails(value) {
|
|
381
|
+
if (value === void 0) return true;
|
|
382
|
+
if (!isRecord(value)) return false;
|
|
383
|
+
for (const [key, detailValue] of Object.entries(value)) {
|
|
384
|
+
if (!isOneOf(agentChannelProviderHealthDetailKeys, key)) return false;
|
|
385
|
+
if (!isChannelProviderHealthDetailValue(key, detailValue)) return false;
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
function isChannelProviderHealthDetailValue(key, value) {
|
|
390
|
+
switch (key) {
|
|
391
|
+
case "closeCode":
|
|
392
|
+
case "reconnectAttempt":
|
|
393
|
+
case "statusCode": return isNonNegativeInteger(value);
|
|
394
|
+
case "providerType": return typeof value === "string" && /^[a-z][a-z0-9._-]{0,31}$/iu.test(value);
|
|
395
|
+
case "reconnecting":
|
|
396
|
+
case "sleepResumeSuspected": return typeof value === "boolean";
|
|
397
|
+
}
|
|
398
|
+
return assertNeverAgentChannelProviderHealthDetailKey(key);
|
|
399
|
+
}
|
|
400
|
+
function assertNeverAgentChannelProviderHealthDetailKey(key) {
|
|
401
|
+
throw new Error(`Unhandled agent channel provider health detail key: ${String(key)}`);
|
|
402
|
+
}
|
|
403
|
+
function isGatewayRecoveryTimeoutErrorCode(value) {
|
|
404
|
+
return value === "recovery-callback-unconfigured" || value === "recovery-timeout";
|
|
405
|
+
}
|
|
406
|
+
function isAgentChannelProviderHealthResultConsistent(health, result) {
|
|
407
|
+
switch (health) {
|
|
408
|
+
case "healthy":
|
|
409
|
+
case "transitioning": return result === "ok";
|
|
410
|
+
case "unhealthy-recoverable":
|
|
411
|
+
case "unhealthy-unrecoverable": return result === "failed";
|
|
412
|
+
}
|
|
413
|
+
return assertNeverAgentChannelProviderHealth(health);
|
|
414
|
+
}
|
|
415
|
+
function hasValidToolVmLeaseLifecycleFields(value) {
|
|
416
|
+
if (!(value.activeUseId !== void 0 || value.callerContextState !== void 0 || value.leaseRejectionReason !== void 0 || value.lifecycleEventRole !== void 0 || value.lifecycleTransition !== void 0 || value.oldLeaseId !== void 0 || value.replacementLeaseId !== void 0 || value.transitionId !== void 0)) return true;
|
|
417
|
+
if (!(optionalNonEmptyString(value.activeUseId) && optionalOneOf(toolVmLeaseCallerContextStates, value.callerContextState) && optionalOneOf(toolVmLeaseRejectionReasons, value.leaseRejectionReason) && isOneOf(toolVmLeaseLifecycleEventRoles, value.lifecycleEventRole) && isOneOf(toolVmLeaseLifecycleTransitions, value.lifecycleTransition) && typeof value.oldLeaseId === "string" && value.oldLeaseId.length > 0 && typeof value.transitionId === "string" && value.transitionId.length > 0)) return false;
|
|
418
|
+
if (isToolVmLeaseReacquiredTransition(value.lifecycleTransition)) return typeof value.replacementLeaseId === "string" && value.replacementLeaseId.length > 0;
|
|
419
|
+
return value.replacementLeaseId === void 0 && optionalOneOf(toolVmLeaseLifecycleEventRoles, value.lifecycleEventRole) && optionalOneOf(toolVmLeaseLifecycleTransitions, value.lifecycleTransition);
|
|
420
|
+
}
|
|
421
|
+
function isAgentVmHealthEvent(value) {
|
|
422
|
+
if (!isRecord(value) || !hasBaseEventFields(value)) return false;
|
|
423
|
+
switch (value.kind) {
|
|
424
|
+
case "caller-context-rejection": return isOneOf([
|
|
425
|
+
"lease_create",
|
|
426
|
+
"lease_get",
|
|
427
|
+
"lease_peek",
|
|
428
|
+
"lease_reacquire",
|
|
429
|
+
"lease_release",
|
|
430
|
+
"lease_renew",
|
|
431
|
+
"lease_use_end",
|
|
432
|
+
"lease_use_heartbeat",
|
|
433
|
+
"lease_use_start"
|
|
434
|
+
], value.operation) && isOneOf([
|
|
435
|
+
"caller_context_absent",
|
|
436
|
+
"caller_context_session_mismatch",
|
|
437
|
+
"caller_context_stale"
|
|
438
|
+
], value.reason) && value.result === "failed";
|
|
439
|
+
case "gateway-service-health": return typeof value.path === "string" && value.path.length > 0 && Number.isInteger(value.port) && optionalStatusCode(value.statusCode);
|
|
440
|
+
case "gateway-control-session": return optionalString(value.bootId) && optionalString(value.connectionId) && value.domain === "gateway_control" && isNonNegativeFiniteNumber(value.elapsedMs) && isOneOf(gatewayControlSessionHealthOperations, value.operation) && typeof value.peerId === "string" && value.peerId.length > 0 && optionalString(value.sessionId);
|
|
441
|
+
case "controller-request": return Number.isInteger(value.attempt) && isNonNegativeFiniteNumber(value.elapsedMs) && optionalString(value.errorCode) && Number.isInteger(value.maxAttempts) && isOneOf(genericControllerRequestEventOperations, value.operation) && optionalStatusCode(value.statusCode);
|
|
442
|
+
case "lease-renew": return typeof value.agentId === "string" && isNonNegativeFiniteNumber(value.elapsedMs) && optionalString(value.errorCode) && typeof value.leaseId === "string";
|
|
443
|
+
case "lease-heartbeat": return typeof value.agentId === "string" && isNonNegativeFiniteNumber(value.elapsedMs) && optionalString(value.errorCode) && typeof value.leaseId === "string" && typeof value.useId === "string";
|
|
444
|
+
case "tool-vm-ssh": return typeof value.agentId === "string" && isNonNegativeFiniteNumber(value.elapsedMs) && optionalString(value.errorCode) && typeof value.leaseId === "string" && isOneOf([
|
|
445
|
+
"command",
|
|
446
|
+
"file-bridge",
|
|
447
|
+
"finalize",
|
|
448
|
+
"probe"
|
|
449
|
+
], value.operation) && hasValidToolVmLeaseLifecycleFields(value);
|
|
450
|
+
case "gateway-plugin-health": return isOneOf(gatewayTypeValues, value.gatewayService) && isOneOf([
|
|
451
|
+
"starting",
|
|
452
|
+
"ready",
|
|
453
|
+
"stopping",
|
|
454
|
+
"failed"
|
|
455
|
+
], value.state);
|
|
456
|
+
case "agent-channel-provider-health": return typeof value.channelProviderId === "string" && value.channelProviderId.length > 0 && isOneOf(agentChannelProviderHealthKinds, value.health) && isOneOf(agentVmHealthResultKinds, value.result) && isAgentChannelProviderHealthResultConsistent(value.health, value.result) && optionalNonNegativeInteger(value.transitionStartedAtMs) && optionalNonNegativeInteger(value.unhealthySinceMs) && isRedactedHealthDetails(value.details);
|
|
457
|
+
case "gateway-recovery":
|
|
458
|
+
if (!isOneOf([
|
|
459
|
+
"gateway-vm-cold-start",
|
|
460
|
+
"gateway-vm-restart",
|
|
461
|
+
"observe-only",
|
|
462
|
+
"operator-required"
|
|
463
|
+
], value.action) || !isNonNegativeInteger(value.consecutiveFailures) || !isPositiveInteger(value.cooldownMs) || !isNonNegativeFiniteNumber(value.elapsedMs) || !isOneOf(gatewayRecoveryHealthReasons, value.reason) || !optionalString(value.operationId)) return false;
|
|
464
|
+
if (value.result === "ok") {
|
|
465
|
+
if (value.action !== "gateway-vm-cold-start" && value.action !== "gateway-vm-restart") return false;
|
|
466
|
+
if (!(isNonNegativeInteger(value.leaseReleaseFailureCount) && typeof value.newBootedAt === "string" && isNonNegativeInteger(value.newHostPid) && typeof value.newVmId === "string" && value.errorCode === void 0)) return false;
|
|
467
|
+
if (value.action === "gateway-vm-cold-start") return value.oldBootedAt === void 0 && value.oldHostPid === void 0 && value.oldVmId === void 0;
|
|
468
|
+
return typeof value.oldBootedAt === "string" && isNonNegativeInteger(value.oldHostPid) && typeof value.oldVmId === "string";
|
|
469
|
+
}
|
|
470
|
+
if (value.result === "failed") {
|
|
471
|
+
if (typeof value.errorCode !== "string" || value.errorCode.length === 0 || !optionalNonNegativeInteger(value.leaseReleaseFailureCount) || value.newBootedAt !== void 0 || value.newHostPid !== void 0 || value.newVmId !== void 0 || !optionalString(value.operationId)) return false;
|
|
472
|
+
if (value.action === "gateway-vm-restart") {
|
|
473
|
+
if (isGatewayRecoveryTimeoutErrorCode(value.errorCode)) return optionalString(value.oldBootedAt) && optionalNonNegativeInteger(value.oldHostPid) && optionalString(value.oldVmId);
|
|
474
|
+
return optionalString(value.oldBootedAt) && optionalNonNegativeInteger(value.oldHostPid) && typeof value.oldVmId === "string" && value.oldVmId.length > 0;
|
|
475
|
+
}
|
|
476
|
+
return (value.action === "gateway-vm-cold-start" || value.action === "observe-only" || value.action === "operator-required") && value.oldBootedAt === void 0 && value.oldHostPid === void 0 && value.oldVmId === void 0;
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
case "gateway-recovery-suspended": return isOneOf([
|
|
480
|
+
"gateway-vm-cold-start",
|
|
481
|
+
"gateway-vm-restart",
|
|
482
|
+
"observe-only",
|
|
483
|
+
"operator-required"
|
|
484
|
+
], value.action) && isNonNegativeInteger(value.consecutiveFailedRecoveries) && isNonNegativeInteger(value.consecutiveFailures) && isPositiveInteger(value.cooldownMs) && value.errorCode === "max-failed-recoveries" && isPositiveInteger(value.failedRecoveryResetMs) && optionalString(value.operationId) && isOneOf(gatewayRecoveryHealthReasons, value.reason) && value.result === "failed";
|
|
485
|
+
default: return false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function healthEventBucketKey(event) {
|
|
489
|
+
switch (event.kind) {
|
|
490
|
+
case "caller-context-rejection": return `${event.zoneId}:${event.kind}:${event.operation}:${event.reason}`;
|
|
491
|
+
case "gateway-control-session": return `${event.zoneId}:${event.kind}`;
|
|
492
|
+
case "gateway-service-health": return `${event.zoneId}:${event.kind}`;
|
|
493
|
+
case "controller-request": return `${event.zoneId}:${event.kind}:${event.operation}`;
|
|
494
|
+
case "lease-heartbeat": return `${event.zoneId}:${event.kind}:${event.leaseId}:${event.useId}`;
|
|
495
|
+
case "lease-renew": return `${event.zoneId}:${event.kind}:${event.leaseId}`;
|
|
496
|
+
case "tool-vm-ssh":
|
|
497
|
+
if (event.transitionId !== void 0) return `${event.zoneId}:${event.kind}:lifecycle:${event.transitionId}`;
|
|
498
|
+
return `${event.zoneId}:${event.kind}:${event.leaseId}:${event.operation}`;
|
|
499
|
+
case "gateway-plugin-health": return `${event.zoneId}:${event.kind}:${event.gatewayService}`;
|
|
500
|
+
case "agent-channel-provider-health": return `${event.zoneId}:${event.kind}:${event.channelProviderId}`;
|
|
501
|
+
case "gateway-recovery": return `${event.zoneId}:${event.kind}:${event.action}`;
|
|
502
|
+
case "gateway-recovery-suspended": return `${event.zoneId}:${event.kind}:${event.action}`;
|
|
503
|
+
}
|
|
504
|
+
return assertNeverHealthEvent(event);
|
|
505
|
+
}
|
|
506
|
+
function isHealthIssueBearingEvent(event) {
|
|
507
|
+
return event.kind !== "caller-context-rejection";
|
|
508
|
+
}
|
|
509
|
+
function failedIssueKindForEvent(event) {
|
|
510
|
+
switch (event.kind) {
|
|
511
|
+
case "gateway-service-health": return "gateway-service-unhealthy";
|
|
512
|
+
case "gateway-control-session": return "gateway-control-session-unhealthy";
|
|
513
|
+
case "controller-request": return "controller-request-failing";
|
|
514
|
+
case "lease-heartbeat": return "lease-heartbeat-failing";
|
|
515
|
+
case "lease-renew": return "lease-renew-failing";
|
|
516
|
+
case "tool-vm-ssh": return "tool-vm-ssh-failing";
|
|
517
|
+
case "gateway-plugin-health": return "gateway-plugin-unhealthy";
|
|
518
|
+
case "agent-channel-provider-health": return "agent-channel-provider-unhealthy";
|
|
519
|
+
case "gateway-recovery": return "gateway-recovery-failed";
|
|
520
|
+
case "gateway-recovery-suspended": return "gateway-recovery-suspended";
|
|
521
|
+
}
|
|
522
|
+
return assertNeverHealthEvent(event);
|
|
523
|
+
}
|
|
524
|
+
function assertNeverHealthEvent(event) {
|
|
525
|
+
throw new Error(`Unhandled health event kind: ${JSON.stringify(event)}`);
|
|
526
|
+
}
|
|
527
|
+
function assertNeverAgentChannelProviderHealth(health) {
|
|
528
|
+
throw new Error(`Unhandled agent channel provider health: ${String(health)}`);
|
|
529
|
+
}
|
|
530
|
+
function issueForEvent(event, options) {
|
|
531
|
+
if (!isHealthIssueBearingEvent(event)) return;
|
|
532
|
+
if (options.nowMs - event.observedAtMs > options.staleAfterMs && !isNonStalingSuccessfulEvent(event)) return {
|
|
533
|
+
kind: "health-event-stale",
|
|
534
|
+
latestEvent: event,
|
|
535
|
+
message: `${event.kind} health event is stale`,
|
|
536
|
+
sinceMs: event.observedAtMs
|
|
537
|
+
};
|
|
538
|
+
if (event.result === "failed" || event.result === "timeout" || event.result === "stale") return {
|
|
539
|
+
kind: failedIssueKindForEvent(event),
|
|
540
|
+
latestEvent: event,
|
|
541
|
+
message: `${event.kind} health event reported ${event.result}`,
|
|
542
|
+
sinceMs: event.observedAtMs
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
function isNonStalingSuccessfulEvent(event) {
|
|
546
|
+
return event.kind === "gateway-recovery" && event.result === "ok";
|
|
547
|
+
}
|
|
548
|
+
function isReacquiredControllerFinalEvent(event) {
|
|
549
|
+
return event.kind === "tool-vm-ssh" && event.lifecycleEventRole === "controller_final" && isToolVmLeaseReacquiredTransition(event.lifecycleTransition) && event.result === "ok";
|
|
550
|
+
}
|
|
551
|
+
function latestReacquiredAtMsByOldLeaseId(events) {
|
|
552
|
+
const latestByOldLeaseId = /* @__PURE__ */ new Map();
|
|
553
|
+
for (const event of events) {
|
|
554
|
+
if (!isReacquiredControllerFinalEvent(event)) continue;
|
|
555
|
+
const previousObservedAtMs = latestByOldLeaseId.get(event.oldLeaseId);
|
|
556
|
+
if (previousObservedAtMs === void 0 || previousObservedAtMs < event.observedAtMs) latestByOldLeaseId.set(event.oldLeaseId, event.observedAtMs);
|
|
557
|
+
}
|
|
558
|
+
return latestByOldLeaseId;
|
|
559
|
+
}
|
|
560
|
+
function isPlainToolVmSshEvent(event) {
|
|
561
|
+
return event.kind === "tool-vm-ssh" && event.transitionId === void 0;
|
|
562
|
+
}
|
|
563
|
+
function filterSupersededToolVmSshEvents(events) {
|
|
564
|
+
const latestReacquiredAtByOldLeaseId = latestReacquiredAtMsByOldLeaseId(events);
|
|
565
|
+
return events.filter((event) => {
|
|
566
|
+
if (!isPlainToolVmSshEvent(event)) return true;
|
|
567
|
+
const reacquiredAtMs = latestReacquiredAtByOldLeaseId.get(event.leaseId);
|
|
568
|
+
return reacquiredAtMs === void 0 || reacquiredAtMs < event.observedAtMs;
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
function deriveZoneHealthSnapshot(events, options) {
|
|
572
|
+
const latestByKey = /* @__PURE__ */ new Map();
|
|
573
|
+
for (const event of events) {
|
|
574
|
+
if (event.zoneId !== options.zoneId || event.kind === "caller-context-rejection") continue;
|
|
575
|
+
const key = healthEventBucketKey(event);
|
|
576
|
+
const previous = latestByKey.get(key);
|
|
577
|
+
if (!previous || previous.observedAtMs <= event.observedAtMs) latestByKey.set(key, event);
|
|
578
|
+
}
|
|
579
|
+
const latestEvents = filterSupersededToolVmSshEvents([...latestByKey.values()]).toSorted((first, second) => second.observedAtMs - first.observedAtMs);
|
|
580
|
+
if (latestEvents.length === 0) return {
|
|
581
|
+
kind: "unknown",
|
|
582
|
+
reason: "no-events",
|
|
583
|
+
zoneId: options.zoneId
|
|
584
|
+
};
|
|
585
|
+
const issues = latestEvents.map((event) => issueForEvent(event, options)).filter((issue) => issue !== void 0);
|
|
586
|
+
if (issues.length === 0) return {
|
|
587
|
+
kind: "ok",
|
|
588
|
+
latestEvents,
|
|
589
|
+
zoneId: options.zoneId
|
|
590
|
+
};
|
|
591
|
+
if (issues.some((issue) => issue.kind === "health-event-stale")) return {
|
|
592
|
+
issues,
|
|
593
|
+
kind: "stale",
|
|
594
|
+
latestEvents,
|
|
595
|
+
zoneId: options.zoneId
|
|
596
|
+
};
|
|
597
|
+
return {
|
|
598
|
+
issues,
|
|
599
|
+
kind: "failed",
|
|
600
|
+
latestEvents,
|
|
601
|
+
zoneId: options.zoneId
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
//#endregion
|
|
605
|
+
//#region src/git-read-allowlist.ts
|
|
606
|
+
function trimGitSuffix(repoPath) {
|
|
607
|
+
return repoPath.replace(/\.git$/u, "");
|
|
608
|
+
}
|
|
609
|
+
function normalizeRepoPath(repoPath) {
|
|
610
|
+
const trimmedRepoPath = trimGitSuffix(repoPath.trim().replace(/^\/+/u, ""));
|
|
611
|
+
const segments = trimmedRepoPath.split("/");
|
|
612
|
+
if (segments.length < 2 || segments.some((segment) => !/^[A-Za-z0-9_.-]+$/u.test(segment))) return;
|
|
613
|
+
return trimmedRepoPath;
|
|
614
|
+
}
|
|
615
|
+
function normalizeHost(host) {
|
|
616
|
+
const normalizedHost = host.trim().toLowerCase();
|
|
617
|
+
if (!/^[a-z0-9.-]+$/u.test(normalizedHost) || normalizedHost.startsWith(".")) return;
|
|
618
|
+
return normalizedHost;
|
|
619
|
+
}
|
|
620
|
+
function normalizeGitRepoForSshReadAllowlist(repoUrl) {
|
|
621
|
+
const trimmedRepoUrl = repoUrl.trim();
|
|
622
|
+
if (trimmedRepoUrl.length === 0) return;
|
|
623
|
+
let parsedUrl;
|
|
624
|
+
try {
|
|
625
|
+
parsedUrl = new URL(trimmedRepoUrl);
|
|
626
|
+
} catch {
|
|
627
|
+
parsedUrl = void 0;
|
|
628
|
+
}
|
|
629
|
+
if (parsedUrl !== void 0) {
|
|
630
|
+
const host = normalizeHost(parsedUrl.hostname);
|
|
631
|
+
const repoPath = normalizeRepoPath(parsedUrl.pathname);
|
|
632
|
+
return host === void 0 || repoPath === void 0 ? void 0 : {
|
|
633
|
+
host,
|
|
634
|
+
repoPath
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
const scpMatch = /^(?:[^@]+@)?(?<host>[A-Za-z0-9.-]+):(?<repoPath>[^:]+)$/u.exec(trimmedRepoUrl);
|
|
638
|
+
if (scpMatch?.groups?.host !== void 0 && scpMatch.groups.repoPath !== void 0) {
|
|
639
|
+
const host = normalizeHost(scpMatch.groups.host);
|
|
640
|
+
const repoPath = normalizeRepoPath(scpMatch.groups.repoPath);
|
|
641
|
+
return host === void 0 || repoPath === void 0 ? void 0 : {
|
|
642
|
+
host,
|
|
643
|
+
repoPath
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
const hostPathMatch = /^(?<host>[A-Za-z0-9.-]+)\/(?<repoPath>.+)$/u.exec(trimmedRepoUrl);
|
|
647
|
+
if (hostPathMatch?.groups?.host !== void 0 && hostPathMatch.groups.repoPath !== void 0) {
|
|
648
|
+
const repoPathWithoutHost = normalizeRepoPath(hostPathMatch.groups.repoPath);
|
|
649
|
+
const host = normalizeHost(hostPathMatch.groups.host);
|
|
650
|
+
if (host !== void 0 && repoPathWithoutHost !== void 0 && host.includes(".")) return {
|
|
651
|
+
host,
|
|
652
|
+
repoPath: repoPathWithoutHost
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const bareRepoPath = normalizeRepoPath(trimmedRepoUrl);
|
|
656
|
+
if (bareRepoPath !== void 0) return {
|
|
657
|
+
host: "github.com",
|
|
658
|
+
repoPath: bareRepoPath
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function normalizeGitReposForSshReadAllowlist(repoUrls) {
|
|
662
|
+
if (repoUrls === void 0) return {
|
|
663
|
+
allowedHosts: [],
|
|
664
|
+
allowedRepos: []
|
|
665
|
+
};
|
|
666
|
+
const allowedHosts = /* @__PURE__ */ new Set();
|
|
667
|
+
const allowedRepos = /* @__PURE__ */ new Set();
|
|
668
|
+
for (const repoUrl of repoUrls) {
|
|
669
|
+
const normalizedRepo = normalizeGitRepoForSshReadAllowlist(repoUrl);
|
|
670
|
+
if (normalizedRepo !== void 0) {
|
|
671
|
+
allowedHosts.add(normalizedRepo.host);
|
|
672
|
+
allowedRepos.add(normalizedRepo.repoPath);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
allowedHosts: [...allowedHosts].toSorted(),
|
|
677
|
+
allowedRepos: [...allowedRepos].toSorted()
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function normalizeGitHubRepoForSshReadAllowlist(repoUrl) {
|
|
681
|
+
const trimmedRepoUrl = repoUrl.trim();
|
|
682
|
+
if (trimmedRepoUrl.length === 0) return;
|
|
683
|
+
const scpMatch = /^git@github\.com:(?<repoPath>[^:]+\/[^/]+)$/iu.exec(trimmedRepoUrl);
|
|
684
|
+
if (scpMatch?.groups?.repoPath !== void 0) return normalizeRepoPath(scpMatch.groups.repoPath);
|
|
685
|
+
const hostPathMatch = /^(?:github\.com\/)(?<repoPath>[^/]+\/[^/]+)$/iu.exec(trimmedRepoUrl);
|
|
686
|
+
if (hostPathMatch?.groups?.repoPath !== void 0) return normalizeRepoPath(hostPathMatch.groups.repoPath);
|
|
687
|
+
const bareRepoPath = normalizeRepoPath(trimmedRepoUrl);
|
|
688
|
+
if (bareRepoPath !== void 0) return bareRepoPath;
|
|
689
|
+
let parsedUrl;
|
|
690
|
+
try {
|
|
691
|
+
parsedUrl = new URL(trimmedRepoUrl);
|
|
692
|
+
} catch {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (parsedUrl.hostname.toLowerCase() !== "github.com") return;
|
|
696
|
+
return normalizeRepoPath(parsedUrl.pathname);
|
|
697
|
+
}
|
|
698
|
+
function normalizeGitHubReposForSshReadAllowlist(repoUrls) {
|
|
699
|
+
return normalizeGitReposForSshReadAllowlist(repoUrls).allowedRepos;
|
|
700
|
+
}
|
|
701
|
+
//#endregion
|
|
702
|
+
//#region src/force-ipv4-egress.ts
|
|
703
|
+
/**
|
|
704
|
+
* Canonical NODE_OPTIONS value for forcing IPv4-preference egress
|
|
705
|
+
* in agent-vm VMs.
|
|
706
|
+
*
|
|
707
|
+
* Background: the managed VM synthetic DNS path (when tcpHosts is enabled)
|
|
708
|
+
* returns a per-host IPv4 (reverse-lookable) and a single shared
|
|
709
|
+
* IPv4-mapped IPv6 (::ffff:198.18.0.1, NOT reverse-lookable). Node
|
|
710
|
+
* 20+'s fetch (via undici, autoSelectFamily: true) races both
|
|
711
|
+
* families; when the IPv6 race wins (~5-20% under sequential load),
|
|
712
|
+
* the VM egress router cannot route it and the request fails with a non-JSON
|
|
713
|
+
* 400 (HTTP) or 403 (TLS). The two flags below stop the race:
|
|
714
|
+
*
|
|
715
|
+
* --dns-result-order=ipv4first changes dns.lookup() so
|
|
716
|
+
* IPv4 addresses are listed
|
|
717
|
+
* before IPv6.
|
|
718
|
+
*
|
|
719
|
+
* --no-network-family-autoselection disables Node's Happy
|
|
720
|
+
* Eyeballs entirely. This is
|
|
721
|
+
* the load-bearing flag —
|
|
722
|
+
* --dns-result-order alone
|
|
723
|
+
* doesn't prevent Node from
|
|
724
|
+
* racing both families if
|
|
725
|
+
* IPv4 is slow.
|
|
726
|
+
*
|
|
727
|
+
* Composition: NODE_OPTIONS is whitespace-separated. To add more
|
|
728
|
+
* flags downstream, append rather than replace. Example:
|
|
729
|
+
*
|
|
730
|
+
* NODE_OPTIONS: `${FORCE_IPV4_EGRESS_NODE_OPTIONS} --inspect`
|
|
731
|
+
*
|
|
732
|
+
* Reference: see `shravan-claw@0ddf5f2:docs/wip/debugging/
|
|
733
|
+
* 2026-05-21-lease-keepalive-400-and-discord-403-ipv6-race.md`
|
|
734
|
+
* for the full root-cause analysis. Node-side flag references:
|
|
735
|
+
* https://github.com/nodejs/node/issues/54359 (autoSelectFamily
|
|
736
|
+
* revert recommendation by the Node core team).
|
|
737
|
+
*/
|
|
738
|
+
const FORCE_IPV4_EGRESS_NODE_OPTIONS = "--dns-result-order=ipv4first --no-network-family-autoselection";
|
|
739
|
+
const FORCE_IPV4_EGRESS_NODE_OPTION_FLAGS = FORCE_IPV4_EGRESS_NODE_OPTIONS.split(/\s+/u);
|
|
740
|
+
/**
|
|
741
|
+
* Compose the forced IPv4-preference flags with a user-provided
|
|
742
|
+
* NODE_OPTIONS value (if any).
|
|
743
|
+
*
|
|
744
|
+
* Use this at every site where NODE_OPTIONS is set into a VM env
|
|
745
|
+
* block AFTER a spread of user-controlled secrets, to guarantee
|
|
746
|
+
* the forced flags are always present in the final value even if
|
|
747
|
+
* a zone secret happens to provide its own NODE_OPTIONS.
|
|
748
|
+
*
|
|
749
|
+
* Forced flags come FIRST so they are unambiguously applied.
|
|
750
|
+
* User-provided flags are appended verbatim except for duplicate
|
|
751
|
+
* forced IPv4-preference flags. Node treats NODE_OPTIONS as a
|
|
752
|
+
* whitespace-separated list and all flags apply.
|
|
753
|
+
*
|
|
754
|
+
* Returns just the forced flags if the user value is undefined,
|
|
755
|
+
* empty, or whitespace-only.
|
|
756
|
+
*
|
|
757
|
+
* Examples:
|
|
758
|
+
*
|
|
759
|
+
* composeNodeOptions(undefined)
|
|
760
|
+
* ──► '--dns-result-order=ipv4first --no-network-family-autoselection'
|
|
761
|
+
*
|
|
762
|
+
* composeNodeOptions('')
|
|
763
|
+
* ──► '--dns-result-order=ipv4first --no-network-family-autoselection'
|
|
764
|
+
*
|
|
765
|
+
* composeNodeOptions('--inspect=0.0.0.0:9229')
|
|
766
|
+
* ──► '--dns-result-order=ipv4first --no-network-family-autoselection
|
|
767
|
+
* --inspect=0.0.0.0:9229'
|
|
768
|
+
*/
|
|
769
|
+
function composeNodeOptions(userValue) {
|
|
770
|
+
const trimmed = userValue?.trim() ?? "";
|
|
771
|
+
if (trimmed === "") return FORCE_IPV4_EGRESS_NODE_OPTIONS;
|
|
772
|
+
const userFlags = trimmed.split(/\s+/u).filter((flag) => !FORCE_IPV4_EGRESS_NODE_OPTION_FLAGS.includes(flag));
|
|
773
|
+
if (userFlags.length === 0) return FORCE_IPV4_EGRESS_NODE_OPTIONS;
|
|
774
|
+
return `${FORCE_IPV4_EGRESS_NODE_OPTIONS} ${userFlags.join(" ")}`;
|
|
775
|
+
}
|
|
776
|
+
//#endregion
|
|
777
|
+
//#region src/managed-gateway-boot-contract.ts
|
|
778
|
+
function assertPlainRecord(value, label) {
|
|
779
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a plain object.`);
|
|
780
|
+
const prototype = Reflect.getPrototypeOf(value);
|
|
781
|
+
if (prototype !== Object.prototype && prototype !== null) throw new Error(`${label} must be a plain object.`);
|
|
782
|
+
}
|
|
783
|
+
function assertExactFields(record, label, requiredFields) {
|
|
784
|
+
const requiredFieldSet = new Set(requiredFields);
|
|
785
|
+
for (const fieldKey of Reflect.ownKeys(record)) {
|
|
786
|
+
if (typeof fieldKey !== "string") throw new Error(`${label} has an unknown symbol field.`);
|
|
787
|
+
const fieldName = fieldKey;
|
|
788
|
+
if (!requiredFieldSet.has(fieldName)) throw new Error(`${label} has unknown field '${fieldName}'.`);
|
|
789
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(record, fieldName);
|
|
790
|
+
if (descriptor === void 0 || !Object.hasOwn(descriptor, "value")) throw new Error(`${label}.${fieldName} must be a data field.`);
|
|
791
|
+
}
|
|
792
|
+
for (const fieldName of requiredFields) if (!Object.hasOwn(record, fieldName)) throw new Error(`${label} is missing required field '${fieldName}'.`);
|
|
793
|
+
}
|
|
794
|
+
function parseLiteral(value, expected, label) {
|
|
795
|
+
if (value !== expected) throw new Error(`${label} must be ${JSON.stringify(expected)}.`);
|
|
796
|
+
return expected;
|
|
797
|
+
}
|
|
798
|
+
function parseNonEmptyString(value, label) {
|
|
799
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string.`);
|
|
800
|
+
return value;
|
|
801
|
+
}
|
|
802
|
+
function parseAbsoluteGuestPath(value, label) {
|
|
803
|
+
const guestPath = parseNonEmptyString(value, label);
|
|
804
|
+
if (!guestPath.startsWith("/") || guestPath.includes("\0") || guestPath.includes("//") || guestPath.length > 1 && guestPath.endsWith("/") || guestPath.split("/").some((segment) => segment === "." || segment === "..")) throw new Error(`${label} must be a normalized absolute guest path.`);
|
|
805
|
+
return guestPath;
|
|
806
|
+
}
|
|
807
|
+
function parseHttpPath(value, label) {
|
|
808
|
+
const httpPath = parseNonEmptyString(value, label);
|
|
809
|
+
if (!httpPath.startsWith("/") || httpPath.includes("\0")) throw new Error(`${label} must be an absolute HTTP path.`);
|
|
810
|
+
return httpPath;
|
|
811
|
+
}
|
|
812
|
+
function parseGuestPort(value, label) {
|
|
813
|
+
if (!Number.isInteger(value) || typeof value !== "number" || value < 1 || value > 65535) throw new Error(`${label} must be an integer TCP port from 1 through 65535.`);
|
|
814
|
+
return value;
|
|
815
|
+
}
|
|
816
|
+
function parseLogIdentity(value, label) {
|
|
817
|
+
assertPlainRecord(value, label);
|
|
818
|
+
assertExactFields(value, label, ["guestPath", "serviceName"]);
|
|
819
|
+
return Object.freeze({
|
|
820
|
+
guestPath: parseAbsoluteGuestPath(value.guestPath, `${label}.guestPath`),
|
|
821
|
+
serviceName: parseNonEmptyString(value.serviceName, `${label}.serviceName`)
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
function parseToolPortalReadiness(value, label) {
|
|
825
|
+
assertPlainRecord(value, label);
|
|
826
|
+
assertExactFields(value, label, [
|
|
827
|
+
"evidencePath",
|
|
828
|
+
"kind",
|
|
829
|
+
"socketPath"
|
|
830
|
+
]);
|
|
831
|
+
return Object.freeze({
|
|
832
|
+
evidencePath: parseAbsoluteGuestPath(value.evidencePath, `${label}.evidencePath`),
|
|
833
|
+
kind: parseLiteral(value.kind, "tool-portal-evidence", `${label}.kind`),
|
|
834
|
+
socketPath: parseAbsoluteGuestPath(value.socketPath, `${label}.socketPath`)
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
function parseFrameworkReadiness(value, label) {
|
|
838
|
+
assertPlainRecord(value, label);
|
|
839
|
+
assertExactFields(value, label, [
|
|
840
|
+
"guestPort",
|
|
841
|
+
"kind",
|
|
842
|
+
"path"
|
|
843
|
+
]);
|
|
844
|
+
return Object.freeze({
|
|
845
|
+
guestPort: parseGuestPort(value.guestPort, `${label}.guestPort`),
|
|
846
|
+
kind: parseLiteral(value.kind, "framework-http", `${label}.kind`),
|
|
847
|
+
path: parseHttpPath(value.path, `${label}.path`)
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
function parseFrameworkIngress(value, label) {
|
|
851
|
+
assertPlainRecord(value, label);
|
|
852
|
+
assertExactFields(value, label, ["guestPort", "kind"]);
|
|
853
|
+
return Object.freeze({
|
|
854
|
+
guestPort: parseGuestPort(value.guestPort, `${label}.guestPort`),
|
|
855
|
+
kind: parseLiteral(value.kind, "framework-http", `${label}.kind`)
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
function parseToolPortalService(value) {
|
|
859
|
+
const label = "Managed Gateway boot contract toolPortalService";
|
|
860
|
+
assertPlainRecord(value, label);
|
|
861
|
+
assertExactFields(value, label, [
|
|
862
|
+
"bootEntry",
|
|
863
|
+
"configurationInputPath",
|
|
864
|
+
"environmentInputPath",
|
|
865
|
+
"logIdentity",
|
|
866
|
+
"readiness",
|
|
867
|
+
"role"
|
|
868
|
+
]);
|
|
869
|
+
return Object.freeze({
|
|
870
|
+
bootEntry: parseLiteral(value.bootEntry, "agent-vm-gateway-runtime", `${label}.bootEntry`),
|
|
871
|
+
configurationInputPath: parseAbsoluteGuestPath(value.configurationInputPath, `${label}.configurationInputPath`),
|
|
872
|
+
environmentInputPath: parseAbsoluteGuestPath(value.environmentInputPath, `${label}.environmentInputPath`),
|
|
873
|
+
logIdentity: parseLogIdentity(value.logIdentity, `${label}.logIdentity`),
|
|
874
|
+
readiness: parseToolPortalReadiness(value.readiness, `${label}.readiness`),
|
|
875
|
+
role: parseLiteral(value.role, "tool-portal-service", `${label}.role`)
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
function parseFrameworkService(value) {
|
|
879
|
+
const label = "Managed Gateway boot contract frameworkService";
|
|
880
|
+
assertPlainRecord(value, label);
|
|
881
|
+
assertExactFields(value, label, [
|
|
882
|
+
"bootEntry",
|
|
883
|
+
"configurationInputPath",
|
|
884
|
+
"environmentInputPath",
|
|
885
|
+
"framework",
|
|
886
|
+
"ingress",
|
|
887
|
+
"logIdentity",
|
|
888
|
+
"readiness",
|
|
889
|
+
"role"
|
|
890
|
+
]);
|
|
891
|
+
const framework = value.framework;
|
|
892
|
+
if (framework !== "openclaw" && framework !== "hermes") throw new Error(`${label}.framework must be "openclaw" or "hermes".`);
|
|
893
|
+
const ingress = parseFrameworkIngress(value.ingress, `${label}.ingress`);
|
|
894
|
+
const readiness = parseFrameworkReadiness(value.readiness, `${label}.readiness`);
|
|
895
|
+
if (ingress.guestPort !== readiness.guestPort) throw new Error(`${label} ingress and readiness must identify the same guest port.`);
|
|
896
|
+
const commonFields = {
|
|
897
|
+
configurationInputPath: parseAbsoluteGuestPath(value.configurationInputPath, `${label}.configurationInputPath`),
|
|
898
|
+
environmentInputPath: parseAbsoluteGuestPath(value.environmentInputPath, `${label}.environmentInputPath`),
|
|
899
|
+
ingress,
|
|
900
|
+
logIdentity: parseLogIdentity(value.logIdentity, `${label}.logIdentity`),
|
|
901
|
+
readiness,
|
|
902
|
+
role: parseLiteral(value.role, "framework-service", `${label}.role`)
|
|
903
|
+
};
|
|
904
|
+
if (framework === "openclaw") return Object.freeze({
|
|
905
|
+
...commonFields,
|
|
906
|
+
bootEntry: parseLiteral(value.bootEntry, "openclaw-gateway", `${label}.bootEntry`),
|
|
907
|
+
framework
|
|
908
|
+
});
|
|
909
|
+
return Object.freeze({
|
|
910
|
+
...commonFields,
|
|
911
|
+
bootEntry: parseLiteral(value.bootEntry, "hermes-gateway", `${label}.bootEntry`),
|
|
912
|
+
framework
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Parses untrusted or serialized boot metadata into the exact V1 contract.
|
|
917
|
+
* Unknown fields are rejected at every level and a fresh deeply frozen value
|
|
918
|
+
* is returned so callers cannot retain mutation authority after validation.
|
|
919
|
+
*/
|
|
920
|
+
function parseManagedGatewayBootContract(value) {
|
|
921
|
+
const label = "Managed Gateway boot contract";
|
|
922
|
+
assertPlainRecord(value, label);
|
|
923
|
+
assertExactFields(value, label, [
|
|
924
|
+
"contractVersion",
|
|
925
|
+
"frameworkService",
|
|
926
|
+
"kind",
|
|
927
|
+
"toolPortalService"
|
|
928
|
+
]);
|
|
929
|
+
return Object.freeze({
|
|
930
|
+
contractVersion: parseLiteral(value.contractVersion, 1, `${label}.contractVersion`),
|
|
931
|
+
frameworkService: parseFrameworkService(value.frameworkService),
|
|
932
|
+
kind: parseLiteral(value.kind, "managed-gateway-exact-two-role", `${label}.kind`),
|
|
933
|
+
toolPortalService: parseToolPortalService(value.toolPortalService)
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region src/split-resolved-gateway-secrets.ts
|
|
938
|
+
function splitResolvedSecretsByInjection(secretConfigs, resolvedSecrets, options) {
|
|
939
|
+
const environmentSecrets = {};
|
|
940
|
+
const mediatedSecrets = {};
|
|
941
|
+
const logPrefix = options.logPrefix ?? "split-resolved-secrets";
|
|
942
|
+
for (const [secretName, secretValue] of Object.entries(resolvedSecrets)) {
|
|
943
|
+
const secretConfig = secretConfigs[secretName];
|
|
944
|
+
if (!secretConfig) throw new Error(`[${logPrefix}] Secret '${secretName}' was resolved but has no matching secret config.`);
|
|
945
|
+
if (!targetsAudience(secretConfig.audience, options.audience)) continue;
|
|
946
|
+
if (secretConfig.injection === "http-mediation") {
|
|
947
|
+
if (secretConfig.hosts.length === 0) throw new Error(`[${logPrefix}] Secret '${secretName}' uses http-mediation but declares no hosts.`);
|
|
948
|
+
mediatedSecrets[secretName] = {
|
|
949
|
+
hosts: [...secretConfig.hosts],
|
|
950
|
+
value: secretValue
|
|
951
|
+
};
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const envSecretAudience = secretConfig.audience;
|
|
955
|
+
if (envSecretAudience !== "gateway") throw new Error(`[${logPrefix}] Secret '${secretName}' uses env injection with non-gateway audience '${envSecretAudience}'.`);
|
|
956
|
+
if (options.audience === "gateway") environmentSecrets[secretName] = secretValue;
|
|
957
|
+
}
|
|
958
|
+
return {
|
|
959
|
+
environmentSecrets,
|
|
960
|
+
mediatedSecrets
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
function splitResolvedGatewaySecrets(zone, resolvedSecrets) {
|
|
964
|
+
return splitResolvedSecretsByInjection(zone.secrets, resolvedSecrets, {
|
|
965
|
+
audience: "gateway",
|
|
966
|
+
logPrefix: "split-resolved-gateway-secrets"
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
function assertNoRuntimeSecretCollision(secretName, target, baseSecrets, runtimeSeen, logPrefix) {
|
|
970
|
+
if (runtimeSeen.has(secretName)) throw new Error(`[${logPrefix}] Runtime gateway secret '${secretName}' is declared for both environment and http-mediation injection.`);
|
|
971
|
+
if (secretName in baseSecrets.environmentSecrets) throw new Error(`[${logPrefix}] Runtime gateway ${target} secret '${secretName}' would overwrite an authored environment secret.`);
|
|
972
|
+
if (secretName in baseSecrets.mediatedSecrets) throw new Error(`[${logPrefix}] Runtime gateway ${target} secret '${secretName}' would overwrite an authored http-mediation secret.`);
|
|
973
|
+
runtimeSeen.add(secretName);
|
|
974
|
+
}
|
|
975
|
+
function mergeRuntimeGatewaySecrets(baseSecrets, options = {}) {
|
|
976
|
+
const logPrefix = options.logPrefix ?? "merge-runtime-gateway-secrets";
|
|
977
|
+
const runtimeSeen = /* @__PURE__ */ new Set();
|
|
978
|
+
for (const secretName of Object.keys(options.runtimeEnvironment ?? {})) assertNoRuntimeSecretCollision(secretName, "environment", baseSecrets, runtimeSeen, logPrefix);
|
|
979
|
+
for (const secretName of Object.keys(options.runtimeMediatedSecrets ?? {})) assertNoRuntimeSecretCollision(secretName, "http-mediation", baseSecrets, runtimeSeen, logPrefix);
|
|
980
|
+
return {
|
|
981
|
+
environmentSecrets: {
|
|
982
|
+
...baseSecrets.environmentSecrets,
|
|
983
|
+
...options.runtimeEnvironment
|
|
984
|
+
},
|
|
985
|
+
mediatedSecrets: {
|
|
986
|
+
...baseSecrets.mediatedSecrets,
|
|
987
|
+
...options.runtimeMediatedSecrets
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
//#endregion
|
|
992
|
+
//#region src/tool-vm-active-use.ts
|
|
993
|
+
const defaultMaxHeartbeatDurationMs = 720 * 60 * 1e3;
|
|
994
|
+
function jitterDelayMs(params) {
|
|
995
|
+
if (params.jitterRatio <= 0) return params.delayMs;
|
|
996
|
+
const spreadMs = params.delayMs * params.jitterRatio;
|
|
997
|
+
const jitteredMs = params.delayMs - spreadMs + params.random() * spreadMs * 2;
|
|
998
|
+
return Math.max(1, Math.round(jitteredMs));
|
|
999
|
+
}
|
|
1000
|
+
function createToolVmActiveUseId() {
|
|
1001
|
+
return v7();
|
|
1002
|
+
}
|
|
1003
|
+
function isToolVmActiveUseId(value) {
|
|
1004
|
+
return validate(value) && version(value) === 7;
|
|
1005
|
+
}
|
|
1006
|
+
function normalizeToolVmActiveUseCorrelation(correlation) {
|
|
1007
|
+
if (!isToolVmActiveUseCorrelationRecord(correlation)) return;
|
|
1008
|
+
const getString = (key) => {
|
|
1009
|
+
const value = correlation[key];
|
|
1010
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1011
|
+
};
|
|
1012
|
+
const normalizedCorrelation = {
|
|
1013
|
+
...getString("messageId") !== void 0 ? { messageId: getString("messageId") } : {},
|
|
1014
|
+
...getString("requestId") !== void 0 ? { requestId: getString("requestId") } : {},
|
|
1015
|
+
...getString("runId") !== void 0 ? { runId: getString("runId") } : {},
|
|
1016
|
+
...getString("sessionKeyDigest") !== void 0 ? { sessionKeyDigest: getString("sessionKeyDigest") } : {},
|
|
1017
|
+
...getString("toolCallId") !== void 0 ? { toolCallId: getString("toolCallId") } : {},
|
|
1018
|
+
...getString("traceId") !== void 0 ? { traceId: getString("traceId") } : {}
|
|
1019
|
+
};
|
|
1020
|
+
return Object.keys(normalizedCorrelation).length > 0 ? normalizedCorrelation : void 0;
|
|
1021
|
+
}
|
|
1022
|
+
function isToolVmActiveUseCorrelationRecord(value) {
|
|
1023
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1024
|
+
}
|
|
1025
|
+
async function createToolVmActiveUseHandle(options) {
|
|
1026
|
+
const useId = createToolVmActiveUseId();
|
|
1027
|
+
const startedUse = await options.startActiveUse({
|
|
1028
|
+
...options.correlation ? { correlation: options.correlation } : {},
|
|
1029
|
+
useId
|
|
1030
|
+
});
|
|
1031
|
+
const setTimeoutImpl = options.setTimeoutImpl ?? setTimeout;
|
|
1032
|
+
const clearTimeoutImpl = options.clearTimeoutImpl ?? clearTimeout;
|
|
1033
|
+
const now = options.nowImpl ?? Date.now;
|
|
1034
|
+
const startedAt = now();
|
|
1035
|
+
const maxHeartbeatDurationMs = options.maxHeartbeatDurationMs ?? defaultMaxHeartbeatDurationMs;
|
|
1036
|
+
const heartbeatJitterRatio = options.heartbeatJitterRatio ?? .1;
|
|
1037
|
+
const random = options.randomImpl ?? Math.random;
|
|
1038
|
+
const operationAbortController = new AbortController();
|
|
1039
|
+
let ended = false;
|
|
1040
|
+
let heartbeatTimer;
|
|
1041
|
+
let latestReport;
|
|
1042
|
+
const clearHeartbeatTimer = () => {
|
|
1043
|
+
if (heartbeatTimer) {
|
|
1044
|
+
clearTimeoutImpl(heartbeatTimer);
|
|
1045
|
+
heartbeatTimer = void 0;
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
const scheduleHeartbeat = (delayMs) => {
|
|
1049
|
+
if (now() - startedAt >= maxHeartbeatDurationMs) return;
|
|
1050
|
+
clearHeartbeatTimer();
|
|
1051
|
+
heartbeatTimer = setTimeoutImpl(() => {
|
|
1052
|
+
if (now() - startedAt >= maxHeartbeatDurationMs) return;
|
|
1053
|
+
const heartbeatRequest = latestReport === void 0 ? {} : { report: latestReport };
|
|
1054
|
+
options.heartbeatActiveUse(startedUse.useId, heartbeatRequest).then((heartbeat) => {
|
|
1055
|
+
if (!ended) scheduleHeartbeat(heartbeat.heartbeatAfterMs);
|
|
1056
|
+
}).catch((error) => {
|
|
1057
|
+
options.logHeartbeatFailure?.(error);
|
|
1058
|
+
if (options.isHeartbeatErrorRefreshable?.(error) === true && options.onRefreshableHeartbeatFailure) {
|
|
1059
|
+
operationAbortController.abort(error);
|
|
1060
|
+
ended = true;
|
|
1061
|
+
clearHeartbeatTimer();
|
|
1062
|
+
options.onRefreshableHeartbeatFailure(error).catch((staleError) => {
|
|
1063
|
+
options.logHeartbeatFailure?.(staleError);
|
|
1064
|
+
});
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (!ended) scheduleHeartbeat(startedUse.heartbeatAfterMs);
|
|
1068
|
+
});
|
|
1069
|
+
}, jitterDelayMs({
|
|
1070
|
+
delayMs,
|
|
1071
|
+
jitterRatio: heartbeatJitterRatio,
|
|
1072
|
+
random
|
|
1073
|
+
}));
|
|
1074
|
+
};
|
|
1075
|
+
scheduleHeartbeat(startedUse.heartbeatAfterMs);
|
|
1076
|
+
const end = async (outcome = "completed") => {
|
|
1077
|
+
if (ended) return;
|
|
1078
|
+
ended = true;
|
|
1079
|
+
clearHeartbeatTimer();
|
|
1080
|
+
try {
|
|
1081
|
+
await options.endActiveUse(startedUse.useId, {
|
|
1082
|
+
outcome,
|
|
1083
|
+
...latestReport === void 0 ? {} : { report: latestReport }
|
|
1084
|
+
});
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
if (options.isEndErrorTolerable?.(error) === true) {
|
|
1087
|
+
options.logEndFailure?.(error);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
throw error;
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
return {
|
|
1094
|
+
signal: operationAbortController.signal,
|
|
1095
|
+
useId: startedUse.useId,
|
|
1096
|
+
dispose: end,
|
|
1097
|
+
end,
|
|
1098
|
+
report: (report) => {
|
|
1099
|
+
if (ended) return;
|
|
1100
|
+
latestReport = report;
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/runtime-paths/runtime-path-mapping.ts
|
|
1106
|
+
const TOOL_VM_SCRATCH_GUEST_ROOT = "/scratch";
|
|
1107
|
+
const OPENCLAW_STATE_VM_ROOT = "/home/openclaw/.openclaw/state";
|
|
1108
|
+
const OPENCLAW_STATE_SANDBOXES_VM_ROOT = `${OPENCLAW_STATE_VM_ROOT}/sandboxes`;
|
|
1109
|
+
const guidanceNamespaceOrder = [
|
|
1110
|
+
"tool-vm-guest",
|
|
1111
|
+
"openclaw-gateway",
|
|
1112
|
+
"controller-host"
|
|
1113
|
+
];
|
|
1114
|
+
function pathContainsParentTraversal(inputPath) {
|
|
1115
|
+
return inputPath.split(/\/+/u).includes("..");
|
|
1116
|
+
}
|
|
1117
|
+
function normalizeAbsolutePath(inputPath) {
|
|
1118
|
+
return `/${inputPath.split("/").filter((segment) => segment !== "" && segment !== ".").join("/")}`;
|
|
1119
|
+
}
|
|
1120
|
+
function normalizeRoot(rootPath) {
|
|
1121
|
+
const normalizedRoot = normalizeAbsolutePath(rootPath);
|
|
1122
|
+
return normalizedRoot === "/" ? normalizedRoot : normalizedRoot.replace(/\/+$/u, "");
|
|
1123
|
+
}
|
|
1124
|
+
function pathMatchesRoot(candidatePath, rootPath) {
|
|
1125
|
+
return candidatePath === rootPath || candidatePath.startsWith(`${rootPath}/`);
|
|
1126
|
+
}
|
|
1127
|
+
function relativePathForRoot(candidatePath, rootPath) {
|
|
1128
|
+
return candidatePath === rootPath ? "" : candidatePath.slice(rootPath.length + 1);
|
|
1129
|
+
}
|
|
1130
|
+
function joinRootAndRelative(rootPath, relativePath) {
|
|
1131
|
+
return relativePath === "" ? rootPath : `${rootPath}/${relativePath}`;
|
|
1132
|
+
}
|
|
1133
|
+
function runtimeRootIsInvalid(rootPath) {
|
|
1134
|
+
return rootPath.trim() === "" || !rootPath.startsWith("/") || pathContainsParentTraversal(rootPath);
|
|
1135
|
+
}
|
|
1136
|
+
function namespaceShouldShowInGuidance(root, namespace) {
|
|
1137
|
+
if (root.showInGuidance?.[namespace] !== void 0) return root.showInGuidance[namespace];
|
|
1138
|
+
return namespace !== "controller-host";
|
|
1139
|
+
}
|
|
1140
|
+
function allowedPathFormsForMapping(mapping, purpose) {
|
|
1141
|
+
return mapping.roots.flatMap((root) => {
|
|
1142
|
+
if (!root.capabilities[purpose]) return [];
|
|
1143
|
+
const suffix = root.rootPathAllowed ? "[/subpath]" : "/<child>";
|
|
1144
|
+
return guidanceNamespaceOrder.flatMap((namespace) => {
|
|
1145
|
+
const rootPath = root.locations[namespace];
|
|
1146
|
+
if (rootPath === void 0 || !namespaceShouldShowInGuidance(root, namespace)) return [];
|
|
1147
|
+
return [`${normalizeRoot(rootPath)}${suffix}`];
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
function retryGuidanceForMapping(mapping, purpose) {
|
|
1152
|
+
return `Use one of the allowed path forms for ${mapping.id} ${purpose}: ${allowedPathFormsForMapping(mapping, purpose).join(", ")}.`;
|
|
1153
|
+
}
|
|
1154
|
+
function errorResult(params) {
|
|
1155
|
+
return {
|
|
1156
|
+
error: {
|
|
1157
|
+
allowedPathForms: allowedPathFormsForMapping(params.mapping, params.purpose),
|
|
1158
|
+
code: params.code,
|
|
1159
|
+
inputPath: params.inputPath,
|
|
1160
|
+
mappingId: params.mapping.id,
|
|
1161
|
+
message: params.message,
|
|
1162
|
+
purpose: params.purpose,
|
|
1163
|
+
retryGuidance: retryGuidanceForMapping(params.mapping, params.purpose)
|
|
1164
|
+
},
|
|
1165
|
+
ok: false
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
function findBestRootMatch(params) {
|
|
1169
|
+
return params.mapping.roots.flatMap((root) => guidanceNamespaceOrder.flatMap((inputNamespace) => {
|
|
1170
|
+
const rootPath = root.locations[inputNamespace];
|
|
1171
|
+
if (rootPath === void 0) return [];
|
|
1172
|
+
if (params.sourceNamespace !== void 0 && inputNamespace !== params.sourceNamespace) return [];
|
|
1173
|
+
const normalizedRoot = normalizeRoot(rootPath);
|
|
1174
|
+
return pathMatchesRoot(params.inputPath, normalizedRoot) ? [{
|
|
1175
|
+
inputNamespace,
|
|
1176
|
+
matchedRoot: normalizedRoot,
|
|
1177
|
+
root
|
|
1178
|
+
}] : [];
|
|
1179
|
+
})).toSorted((left, right) => right.matchedRoot.length - left.matchedRoot.length)[0];
|
|
1180
|
+
}
|
|
1181
|
+
function findInvalidRoot(mapping) {
|
|
1182
|
+
for (const root of mapping.roots) for (const namespace of guidanceNamespaceOrder) {
|
|
1183
|
+
const rootPath = root.locations[namespace];
|
|
1184
|
+
if (rootPath !== void 0 && runtimeRootIsInvalid(rootPath)) return {
|
|
1185
|
+
rootId: root.id,
|
|
1186
|
+
rootPath
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function translateRuntimePath(input) {
|
|
1191
|
+
if (!input.inputPath.startsWith("/")) return errorResult({
|
|
1192
|
+
code: "path-not-absolute",
|
|
1193
|
+
inputPath: input.inputPath,
|
|
1194
|
+
mapping: input.mapping,
|
|
1195
|
+
message: `Path '${input.inputPath}' must be absolute.`,
|
|
1196
|
+
purpose: input.purpose
|
|
1197
|
+
});
|
|
1198
|
+
if (pathContainsParentTraversal(input.inputPath)) return errorResult({
|
|
1199
|
+
code: "path-parent-traversal",
|
|
1200
|
+
inputPath: input.inputPath,
|
|
1201
|
+
mapping: input.mapping,
|
|
1202
|
+
message: `Path '${input.inputPath}' must not contain parent traversal.`,
|
|
1203
|
+
purpose: input.purpose
|
|
1204
|
+
});
|
|
1205
|
+
const invalidRoot = findInvalidRoot(input.mapping);
|
|
1206
|
+
if (invalidRoot !== void 0) return errorResult({
|
|
1207
|
+
code: "invalid-runtime-root",
|
|
1208
|
+
inputPath: input.inputPath,
|
|
1209
|
+
mapping: input.mapping,
|
|
1210
|
+
message: `Runtime path root '${invalidRoot.rootId}' has invalid path '${invalidRoot.rootPath}'.`,
|
|
1211
|
+
purpose: input.purpose
|
|
1212
|
+
});
|
|
1213
|
+
const normalizedInputPath = normalizeAbsolutePath(input.inputPath);
|
|
1214
|
+
const match = findBestRootMatch({
|
|
1215
|
+
inputPath: normalizedInputPath,
|
|
1216
|
+
mapping: input.mapping,
|
|
1217
|
+
...input.sourceNamespace === void 0 ? {} : { sourceNamespace: input.sourceNamespace }
|
|
1218
|
+
});
|
|
1219
|
+
if (match === void 0) return errorResult({
|
|
1220
|
+
code: "unknown-runtime-path",
|
|
1221
|
+
inputPath: normalizedInputPath,
|
|
1222
|
+
mapping: input.mapping,
|
|
1223
|
+
message: `Path '${normalizedInputPath}' is not part of runtime path mapping '${input.mapping.id}'.`,
|
|
1224
|
+
purpose: input.purpose
|
|
1225
|
+
});
|
|
1226
|
+
const relativePath = relativePathForRoot(normalizedInputPath, match.matchedRoot);
|
|
1227
|
+
if (relativePath === "" && !match.root.rootPathAllowed) return errorResult({
|
|
1228
|
+
code: "root-path-not-allowed",
|
|
1229
|
+
inputPath: normalizedInputPath,
|
|
1230
|
+
mapping: input.mapping,
|
|
1231
|
+
message: `Path '${normalizedInputPath}' matched ${match.root.guidanceLabel}, but the root itself is not allowed for ${input.purpose}.`,
|
|
1232
|
+
purpose: input.purpose
|
|
1233
|
+
});
|
|
1234
|
+
if (!match.root.capabilities[input.purpose]) return errorResult({
|
|
1235
|
+
code: "purpose-not-allowed",
|
|
1236
|
+
inputPath: normalizedInputPath,
|
|
1237
|
+
mapping: input.mapping,
|
|
1238
|
+
message: `Path '${normalizedInputPath}' matched ${match.root.guidanceLabel} but cannot be used for ${input.purpose}.`,
|
|
1239
|
+
purpose: input.purpose
|
|
1240
|
+
});
|
|
1241
|
+
const targetRoot = match.root.locations[input.targetNamespace];
|
|
1242
|
+
if (targetRoot === void 0) return errorResult({
|
|
1243
|
+
code: "target-namespace-not-available",
|
|
1244
|
+
inputPath: normalizedInputPath,
|
|
1245
|
+
mapping: input.mapping,
|
|
1246
|
+
message: `Path '${normalizedInputPath}' matched ${match.root.guidanceLabel}, but '${input.targetNamespace}' is not available for that root.`,
|
|
1247
|
+
purpose: input.purpose
|
|
1248
|
+
});
|
|
1249
|
+
const normalizedTargetRoot = normalizeRoot(targetRoot);
|
|
1250
|
+
return {
|
|
1251
|
+
ok: true,
|
|
1252
|
+
value: {
|
|
1253
|
+
backing: match.root.backing,
|
|
1254
|
+
capabilities: match.root.capabilities,
|
|
1255
|
+
inputNamespace: match.inputNamespace,
|
|
1256
|
+
inputPath: normalizedInputPath,
|
|
1257
|
+
mappingId: input.mapping.id,
|
|
1258
|
+
outputNamespace: input.targetNamespace,
|
|
1259
|
+
outputPath: joinRootAndRelative(normalizedTargetRoot, relativePath),
|
|
1260
|
+
relativePath,
|
|
1261
|
+
rootId: match.root.id
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
//#endregion
|
|
1266
|
+
//#region src/tool-vm-lease-id.ts
|
|
1267
|
+
function createToolVmLeaseId() {
|
|
1268
|
+
return parseToolVmLeaseId(v7());
|
|
1269
|
+
}
|
|
1270
|
+
function isToolVmLeaseId(value) {
|
|
1271
|
+
return typeof value === "string" && validate(value) && version(value) === 7;
|
|
1272
|
+
}
|
|
1273
|
+
function parseToolVmLeaseId(value) {
|
|
1274
|
+
if (isToolVmLeaseId(value)) return value;
|
|
1275
|
+
throw new TypeError("Tool VM lease id must be an opaque UUIDv7 string.");
|
|
1276
|
+
}
|
|
1277
|
+
//#endregion
|
|
1278
|
+
//#region src/vm-capability-lease.ts
|
|
1279
|
+
const VM_SSH_PUBLIC_ENDPOINT_KEYS = new Set([
|
|
1280
|
+
"host",
|
|
1281
|
+
"port",
|
|
1282
|
+
"user"
|
|
1283
|
+
]);
|
|
1284
|
+
function objectValue$1(value) {
|
|
1285
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
1286
|
+
}
|
|
1287
|
+
function isNonEmptyString(value) {
|
|
1288
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1289
|
+
}
|
|
1290
|
+
function isVmCapabilityLease(value, transport) {
|
|
1291
|
+
const record = objectValue$1(value);
|
|
1292
|
+
return record !== void 0 && typeof Reflect.get(record, "leaseId") === "string" && Reflect.get(record, "transport") === transport;
|
|
1293
|
+
}
|
|
1294
|
+
function isVmSshEndpoint(value) {
|
|
1295
|
+
const record = objectValue$1(value);
|
|
1296
|
+
return record !== void 0 && typeof Reflect.get(record, "host") === "string" && isNonEmptyString(Reflect.get(record, "identityPem")) && isNonEmptyString(Reflect.get(record, "knownHostsLine")) && typeof Reflect.get(record, "port") === "number" && typeof Reflect.get(record, "user") === "string";
|
|
1297
|
+
}
|
|
1298
|
+
function isVmSshPublicEndpoint(value) {
|
|
1299
|
+
const record = objectValue$1(value);
|
|
1300
|
+
if (record === void 0) return false;
|
|
1301
|
+
for (const key of Object.keys(record)) if (!VM_SSH_PUBLIC_ENDPOINT_KEYS.has(key)) return false;
|
|
1302
|
+
return typeof Reflect.get(record, "host") === "string" && typeof Reflect.get(record, "port") === "number" && typeof Reflect.get(record, "user") === "string";
|
|
1303
|
+
}
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/tool-vm-lease.ts
|
|
1306
|
+
const defaultToolVmLeaseAuthorityTombstoneTtlMs = 600 * 1e3;
|
|
1307
|
+
const TOOL_VM_WORK_GUEST_ROOT = "/work";
|
|
1308
|
+
function objectValue(value) {
|
|
1309
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
1310
|
+
}
|
|
1311
|
+
const deprecatedScopeKeyPropertyName = ["scope", "Key"].join("");
|
|
1312
|
+
function isToolVmSshLease(value) {
|
|
1313
|
+
const record = objectValue(value);
|
|
1314
|
+
return isVmCapabilityLease(record, "ssh-sandbox") && isToolVmLeaseId(Reflect.get(record, "leaseId")) && isVmSshEndpoint(Reflect.get(record, "ssh")) && typeof Reflect.get(record, "agentId") === "string" && typeof Reflect.get(record, "idleTtlMs") === "number" && typeof Reflect.get(record, "tcpSlot") === "number" && typeof Reflect.get(record, "workdir") === "string" && !Reflect.has(record, deprecatedScopeKeyPropertyName);
|
|
1315
|
+
}
|
|
1316
|
+
function isToolVmLeasePeek(value) {
|
|
1317
|
+
const record = objectValue(value);
|
|
1318
|
+
return isVmCapabilityLease(record, "ssh-sandbox") && isToolVmLeaseId(Reflect.get(record, "leaseId")) && typeof Reflect.get(record, "agentId") === "string" && typeof Reflect.get(record, "createdAt") === "number" && typeof Reflect.get(record, "idleTtlMs") === "number" && typeof Reflect.get(record, "lastUsedAt") === "number" && typeof Reflect.get(record, "profileId") === "string" && isVmSshPublicEndpoint(Reflect.get(record, "ssh")) && typeof Reflect.get(record, "tcpSlot") === "number" && typeof Reflect.get(record, "workdir") === "string" && typeof Reflect.get(record, "zoneId") === "string" && !Reflect.has(record, deprecatedScopeKeyPropertyName);
|
|
1319
|
+
}
|
|
1320
|
+
//#endregion
|
|
1321
|
+
export { ControllerRequestPolicyTransportError, FORCE_IPV4_EGRESS_NODE_OPTIONS, GATEWAY_CONTROL_CALLER_CONTEXT_AGENT_AUTHORITY_KEYS_ENV, GATEWAY_CONTROL_CALLER_CONTEXT_PROOF_KEY_ENV, GATEWAY_CONTROL_PRIVATE_ENVIRONMENT_NAMES, OPENCLAW_STATE_SANDBOXES_VM_ROOT, OPENCLAW_STATE_VM_ROOT, TOOL_VM_SCRATCH_GUEST_ROOT, TOOL_VM_WORK_GUEST_ROOT, agentVmHealthEventKinds, agentVmHealthResultKinds, buildGatewaySessionLabel, buildToolSessionLabel, composeNodeOptions, controllerRequestPolicies, controllerVmHost, createGatewayTelemetryProducerSafetyContract, createToolVmActiveUseHandle, createToolVmActiveUseId, createToolVmLeaseId, createWebSocketUpgradeRequestGuard, defaultToolVmLeaseAuthorityTombstoneTtlMs, deriveZoneHealthSnapshot, drainControllerResponseBody, egressHostsForAudience, externalControllerRoutes, gatewayControlSessionHealthOperations, gatewayFrameworkTelemetryServiceNames, gatewayRecoveryHealthReasons, gatewayTelemetryAdmissionLimits, gatewayTelemetrySourcePolicy, gatewayToolPortalTelemetryServiceName, gatewayTypeValues, gatewayVmAllowedHosts, genericControllerRequestEventOperations, healthEventBucketKey, isAgentVmHealthEvent, isToolVmActiveUseId, isToolVmLeaseId, isToolVmLeasePeek, isToolVmSshLease, isVmCapabilityLease, isVmSshEndpoint, isVmSshPublicEndpoint, mergeRuntimeGatewaySecrets, normalizeGitHubRepoForSshReadAllowlist, normalizeGitHubReposForSshReadAllowlist, normalizeGitRepoForSshReadAllowlist, normalizeGitReposForSshReadAllowlist, normalizeToolVmActiveUseCorrelation, parseManagedGatewayBootContract, parseToolVmLeaseId, splitResolvedGatewaySecrets, splitResolvedSecretsByInjection, targetsAudience, translateRuntimePath, vmAudienceValues, websocketUpgradesForAudience, workerVmAllowedHosts, zoneHealthIssueKinds, zoneHealthStateKinds };
|
|
1322
|
+
|
|
1323
|
+
//# sourceMappingURL=index.js.map
|