@juspay/neurolink 9.95.3 → 10.0.1
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 +23 -0
- package/dist/browser/neurolink.min.js +356 -356
- package/dist/cli/commands/observability.js +114 -103
- package/dist/cli/commands/proxy.d.ts +26 -0
- package/dist/cli/commands/proxy.js +132 -13
- package/dist/cli/commands/telemetry.js +139 -119
- package/dist/cli/factories/commandFactory.d.ts +7 -0
- package/dist/cli/factories/commandFactory.js +29 -21
- package/dist/cli/utils/inputValidation.d.ts +1 -0
- package/dist/cli/utils/inputValidation.js +6 -1
- package/dist/lib/utils/fileDetector.js +38 -10
- package/dist/lib/utils/imageCache.d.ts +10 -2
- package/dist/lib/utils/imageCache.js +12 -23
- package/dist/lib/utils/imageProcessor.d.ts +25 -0
- package/dist/lib/utils/imageProcessor.js +28 -1
- package/dist/lib/utils/logSanitize.d.ts +97 -0
- package/dist/lib/utils/logSanitize.js +163 -0
- package/dist/lib/utils/messageBuilder.js +19 -0
- package/dist/lib/utils/pdfProcessor.d.ts +7 -1
- package/dist/lib/utils/pdfProcessor.js +27 -3
- package/dist/utils/fileDetector.js +38 -10
- package/dist/utils/imageCache.d.ts +10 -2
- package/dist/utils/imageCache.js +12 -23
- package/dist/utils/imageProcessor.d.ts +25 -0
- package/dist/utils/imageProcessor.js +28 -1
- package/dist/utils/logSanitize.d.ts +97 -0
- package/dist/utils/logSanitize.js +163 -0
- package/dist/utils/messageBuilder.js +19 -0
- package/dist/utils/pdfProcessor.d.ts +7 -1
- package/dist/utils/pdfProcessor.js +27 -3
- package/package.json +1 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/* eslint-disable no-console, curly */
|
|
3
2
|
/**
|
|
4
3
|
* NeuroLink CLI Observability Commands
|
|
5
4
|
*
|
|
@@ -14,6 +13,7 @@ import ora from "ora";
|
|
|
14
13
|
import { logger } from "../../lib/utils/logger.js";
|
|
15
14
|
import { NeuroLink } from "../../lib/neurolink.js";
|
|
16
15
|
import { formatRow, formatCost } from "../utils/formatters.js";
|
|
16
|
+
import { redactUrlCredentials } from "../../lib/utils/logSanitize.js";
|
|
17
17
|
/**
|
|
18
18
|
* Observability Command Factory
|
|
19
19
|
*/
|
|
@@ -70,67 +70,72 @@ export class ObservabilityCommandFactory {
|
|
|
70
70
|
try {
|
|
71
71
|
const neurolink = new NeuroLink();
|
|
72
72
|
const status = neurolink.getTelemetryStatus();
|
|
73
|
-
if (spinner)
|
|
73
|
+
if (spinner) {
|
|
74
74
|
spinner.succeed("Status retrieved");
|
|
75
|
+
}
|
|
75
76
|
if (args.format === "json") {
|
|
76
|
-
|
|
77
|
+
logger.always(JSON.stringify(status, (_key, value) => typeof value === "string" &&
|
|
78
|
+
(_key === "baseUrl" || _key === "endpoint")
|
|
79
|
+
? redactUrlCredentials(value)
|
|
80
|
+
: value, 2));
|
|
77
81
|
}
|
|
78
82
|
else {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
logger.always("");
|
|
84
|
+
logger.always(chalk.bold.cyan("=== Observability Status ==="));
|
|
85
|
+
logger.always("");
|
|
82
86
|
// Telemetry enabled status
|
|
83
87
|
const enabledIcon = status.enabled
|
|
84
88
|
? chalk.green("ON")
|
|
85
89
|
: chalk.red("OFF");
|
|
86
|
-
|
|
90
|
+
logger.always(formatRow("Telemetry:", enabledIcon));
|
|
87
91
|
// Langfuse status
|
|
88
92
|
if (status.langfuse) {
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
logger.always("");
|
|
94
|
+
logger.always(chalk.bold("Langfuse:"));
|
|
91
95
|
const lfStatus = status.langfuse.enabled
|
|
92
96
|
? chalk.green("Enabled")
|
|
93
97
|
: chalk.gray("Disabled");
|
|
94
|
-
|
|
98
|
+
logger.always(formatRow(" Status:", lfStatus));
|
|
95
99
|
if (status.langfuse.baseUrl) {
|
|
96
|
-
|
|
100
|
+
logger.always(formatRow(" URL:", redactUrlCredentials(status.langfuse.baseUrl)));
|
|
97
101
|
}
|
|
98
102
|
if (status.langfuse.environment) {
|
|
99
|
-
|
|
103
|
+
logger.always(formatRow(" Environment:", status.langfuse.environment));
|
|
100
104
|
}
|
|
101
105
|
}
|
|
102
106
|
// OpenTelemetry status
|
|
103
107
|
if (status.openTelemetry) {
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
logger.always("");
|
|
109
|
+
logger.always(chalk.bold("OpenTelemetry:"));
|
|
106
110
|
const otelStatus = status.openTelemetry.enabled
|
|
107
111
|
? chalk.green("Enabled")
|
|
108
112
|
: chalk.gray("Disabled");
|
|
109
|
-
|
|
113
|
+
logger.always(formatRow(" Status:", otelStatus));
|
|
110
114
|
if (status.openTelemetry.endpoint) {
|
|
111
|
-
|
|
115
|
+
logger.always(formatRow(" Endpoint:", redactUrlCredentials(status.openTelemetry.endpoint)));
|
|
112
116
|
}
|
|
113
117
|
if (status.openTelemetry.serviceName) {
|
|
114
|
-
|
|
118
|
+
logger.always(formatRow(" Service:", status.openTelemetry.serviceName));
|
|
115
119
|
}
|
|
116
120
|
}
|
|
117
121
|
// Exporters summary
|
|
118
122
|
if (status.exporters && status.exporters.length > 0) {
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
logger.always("");
|
|
124
|
+
logger.always(chalk.bold("Active Exporters:"));
|
|
121
125
|
for (const exporter of status.exporters) {
|
|
122
126
|
const exporterStatus = exporter.healthy
|
|
123
127
|
? chalk.green("Healthy")
|
|
124
128
|
: chalk.red("Unhealthy");
|
|
125
|
-
|
|
129
|
+
logger.always(formatRow(` ${exporter.name}:`, exporterStatus));
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
|
-
|
|
132
|
+
logger.always("");
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
135
|
catch (error) {
|
|
132
|
-
if (spinner)
|
|
136
|
+
if (spinner) {
|
|
133
137
|
spinner.fail("Failed to get status");
|
|
138
|
+
}
|
|
134
139
|
logger.error("Error:", error instanceof Error ? error.message : String(error));
|
|
135
140
|
process.exit(1);
|
|
136
141
|
}
|
|
@@ -171,74 +176,76 @@ export class ObservabilityCommandFactory {
|
|
|
171
176
|
try {
|
|
172
177
|
const neurolink = new NeuroLink();
|
|
173
178
|
const metrics = neurolink.getMetrics();
|
|
174
|
-
if (spinner)
|
|
179
|
+
if (spinner) {
|
|
175
180
|
spinner.succeed("Metrics retrieved");
|
|
181
|
+
}
|
|
176
182
|
if (args.format === "json") {
|
|
177
|
-
|
|
183
|
+
logger.always(JSON.stringify(metrics, null, 2));
|
|
178
184
|
}
|
|
179
185
|
else {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
186
|
+
logger.always("");
|
|
187
|
+
logger.always(chalk.bold.cyan("=== Metrics Summary ==="));
|
|
188
|
+
logger.always("");
|
|
183
189
|
// Request statistics
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
logger.always(chalk.bold("Request Statistics:"));
|
|
191
|
+
logger.always(formatRow(" Total requests:", metrics.totalSpans.toLocaleString()));
|
|
192
|
+
logger.always(formatRow(" Successful:", metrics.successfulSpans.toLocaleString()));
|
|
193
|
+
logger.always(formatRow(" Failed:", metrics.failedSpans.toLocaleString()));
|
|
194
|
+
logger.always(formatRow(" Success rate:", `${(metrics.successRate * 100).toFixed(2)}%`));
|
|
189
195
|
// Latency statistics
|
|
190
196
|
if (metrics.latency && metrics.latency.count > 0) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
logger.always("");
|
|
198
|
+
logger.always(chalk.bold("Latency (ms):"));
|
|
199
|
+
logger.always(formatRow(" Min:", metrics.latency.min.toFixed(2)));
|
|
200
|
+
logger.always(formatRow(" Max:", metrics.latency.max.toFixed(2)));
|
|
201
|
+
logger.always(formatRow(" Mean:", metrics.latency.mean.toFixed(2)));
|
|
202
|
+
logger.always(formatRow(" P50:", metrics.latency.p50.toFixed(2)));
|
|
203
|
+
logger.always(formatRow(" P95:", metrics.latency.p95.toFixed(2)));
|
|
204
|
+
logger.always(formatRow(" P99:", metrics.latency.p99.toFixed(2)));
|
|
199
205
|
if (args.detailed) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
206
|
+
logger.always(formatRow(" P75:", metrics.latency.p75.toFixed(2)));
|
|
207
|
+
logger.always(formatRow(" P90:", metrics.latency.p90.toFixed(2)));
|
|
208
|
+
logger.always(formatRow(" Std Dev:", metrics.latency.stdDev.toFixed(2)));
|
|
203
209
|
}
|
|
204
210
|
}
|
|
205
211
|
// Token statistics
|
|
206
212
|
if (metrics.tokens) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
213
|
+
logger.always("");
|
|
214
|
+
logger.always(chalk.bold("Token Usage:"));
|
|
215
|
+
logger.always(formatRow(" Input tokens:", metrics.tokens.totalInputTokens.toLocaleString()));
|
|
216
|
+
logger.always(formatRow(" Output tokens:", metrics.tokens.totalOutputTokens.toLocaleString()));
|
|
217
|
+
logger.always(formatRow(" Total tokens:", metrics.tokens.totalTokens.toLocaleString()));
|
|
212
218
|
if (args.detailed &&
|
|
213
219
|
metrics.tokens.cacheReadTokens &&
|
|
214
220
|
metrics.tokens.cacheReadTokens > 0) {
|
|
215
|
-
|
|
221
|
+
logger.always(formatRow(" Cache read:", metrics.tokens.cacheReadTokens.toLocaleString()));
|
|
216
222
|
}
|
|
217
223
|
if (args.detailed &&
|
|
218
224
|
metrics.tokens.reasoningTokens &&
|
|
219
225
|
metrics.tokens.reasoningTokens > 0) {
|
|
220
|
-
|
|
226
|
+
logger.always(formatRow(" Reasoning:", metrics.tokens.reasoningTokens.toLocaleString()));
|
|
221
227
|
}
|
|
222
228
|
}
|
|
223
229
|
// Cost
|
|
224
230
|
if (metrics.totalCost !== undefined && metrics.totalCost > 0) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
231
|
+
logger.always("");
|
|
232
|
+
logger.always(chalk.bold("Cost:"));
|
|
233
|
+
logger.always(formatRow(" Total:", formatCost(metrics.totalCost)));
|
|
228
234
|
}
|
|
229
235
|
// Tracking duration
|
|
230
236
|
if (metrics.trackingDurationMs) {
|
|
231
237
|
const durationSec = metrics.trackingDurationMs / 1000;
|
|
232
238
|
const throughput = metrics.totalSpans > 0 ? metrics.totalSpans / durationSec : 0;
|
|
233
|
-
|
|
234
|
-
|
|
239
|
+
logger.always("");
|
|
240
|
+
logger.always(chalk.gray(`Tracking: ${durationSec.toFixed(1)}s (${throughput.toFixed(2)} req/s)`));
|
|
235
241
|
}
|
|
236
|
-
|
|
242
|
+
logger.always("");
|
|
237
243
|
}
|
|
238
244
|
}
|
|
239
245
|
catch (error) {
|
|
240
|
-
if (spinner)
|
|
246
|
+
if (spinner) {
|
|
241
247
|
spinner.fail("Failed to get metrics");
|
|
248
|
+
}
|
|
242
249
|
logger.error("Error:", error instanceof Error ? error.message : String(error));
|
|
243
250
|
process.exit(1);
|
|
244
251
|
}
|
|
@@ -276,60 +283,62 @@ export class ObservabilityCommandFactory {
|
|
|
276
283
|
try {
|
|
277
284
|
const neurolink = new NeuroLink();
|
|
278
285
|
const status = neurolink.getTelemetryStatus();
|
|
279
|
-
if (spinner)
|
|
286
|
+
if (spinner) {
|
|
280
287
|
spinner.succeed("Exporters retrieved");
|
|
288
|
+
}
|
|
281
289
|
const exporters = status.exporters ?? [];
|
|
282
290
|
if (args.format === "json") {
|
|
283
|
-
|
|
291
|
+
logger.always(JSON.stringify(exporters, null, 2));
|
|
284
292
|
}
|
|
285
293
|
else {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
294
|
+
logger.always("");
|
|
295
|
+
logger.always(chalk.bold.cyan("=== Configured Exporters ==="));
|
|
296
|
+
logger.always("");
|
|
289
297
|
if (exporters.length === 0) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
298
|
+
logger.always(chalk.gray("No exporters configured."));
|
|
299
|
+
logger.always("");
|
|
300
|
+
logger.always("Available exporters:");
|
|
301
|
+
logger.always(" - Langfuse (langfuse)");
|
|
302
|
+
logger.always(" - LangSmith (langsmith)");
|
|
303
|
+
logger.always(" - OpenTelemetry (otel)");
|
|
304
|
+
logger.always(" - Datadog (datadog)");
|
|
305
|
+
logger.always(" - Sentry (sentry)");
|
|
306
|
+
logger.always(" - Braintrust (braintrust)");
|
|
307
|
+
logger.always(" - Arize (arize)");
|
|
308
|
+
logger.always(" - PostHog (posthog)");
|
|
309
|
+
logger.always(" - Laminar (laminar)");
|
|
302
310
|
}
|
|
303
311
|
else {
|
|
304
312
|
for (const exporter of exporters) {
|
|
305
313
|
const healthIcon = exporter.healthy
|
|
306
314
|
? chalk.green("[OK]")
|
|
307
315
|
: chalk.red("[ERROR]");
|
|
308
|
-
|
|
316
|
+
logger.always(`${healthIcon} ${chalk.bold(exporter.name)}`);
|
|
309
317
|
if (exporter.latencyMs !== undefined) {
|
|
310
|
-
|
|
318
|
+
logger.always(formatRow(" Latency:", `${exporter.latencyMs.toFixed(2)}ms`));
|
|
311
319
|
}
|
|
312
320
|
if (exporter.pendingSpans !== undefined) {
|
|
313
|
-
|
|
321
|
+
logger.always(formatRow(" Pending spans:", exporter.pendingSpans.toLocaleString()));
|
|
314
322
|
}
|
|
315
323
|
if (exporter.lastExportTime) {
|
|
316
324
|
const lastExport = new Date(exporter.lastExportTime);
|
|
317
|
-
|
|
325
|
+
logger.always(formatRow(" Last export:", lastExport.toISOString()));
|
|
318
326
|
}
|
|
319
327
|
if (exporter.errors && exporter.errors.length > 0) {
|
|
320
|
-
|
|
328
|
+
logger.always(chalk.yellow(" Errors:"));
|
|
321
329
|
for (const error of exporter.errors.slice(0, 3)) {
|
|
322
|
-
|
|
330
|
+
logger.always(chalk.red(` - ${error}`));
|
|
323
331
|
}
|
|
324
332
|
}
|
|
325
|
-
|
|
333
|
+
logger.always("");
|
|
326
334
|
}
|
|
327
335
|
}
|
|
328
336
|
}
|
|
329
337
|
}
|
|
330
338
|
catch (error) {
|
|
331
|
-
if (spinner)
|
|
339
|
+
if (spinner) {
|
|
332
340
|
spinner.fail("Failed to get exporters");
|
|
341
|
+
}
|
|
333
342
|
logger.error("Error:", error instanceof Error ? error.message : String(error));
|
|
334
343
|
process.exit(1);
|
|
335
344
|
}
|
|
@@ -377,8 +386,9 @@ export class ObservabilityCommandFactory {
|
|
|
377
386
|
try {
|
|
378
387
|
const neurolink = new NeuroLink();
|
|
379
388
|
const metrics = neurolink.getMetrics();
|
|
380
|
-
if (spinner)
|
|
389
|
+
if (spinner) {
|
|
381
390
|
spinner.succeed("Costs calculated");
|
|
391
|
+
}
|
|
382
392
|
if (args.format === "json") {
|
|
383
393
|
const jsonOutput = {
|
|
384
394
|
totalCost: metrics.totalCost,
|
|
@@ -389,60 +399,61 @@ export class ObservabilityCommandFactory {
|
|
|
389
399
|
if (args.byModel !== false) {
|
|
390
400
|
jsonOutput.costByModel = metrics.costByModel;
|
|
391
401
|
}
|
|
392
|
-
|
|
402
|
+
logger.always(JSON.stringify(jsonOutput, null, 2));
|
|
393
403
|
}
|
|
394
404
|
else {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
405
|
+
logger.always("");
|
|
406
|
+
logger.always(chalk.bold.cyan("=== Cost Breakdown ==="));
|
|
407
|
+
logger.always("");
|
|
398
408
|
// Total cost
|
|
399
|
-
|
|
409
|
+
logger.always(chalk.bold(`Total Cost: ${formatCost(metrics.totalCost ?? 0)}`));
|
|
400
410
|
// Cost by provider
|
|
401
411
|
if (args.byProvider !== false &&
|
|
402
412
|
metrics.costByProvider &&
|
|
403
413
|
metrics.costByProvider.length > 0) {
|
|
404
|
-
|
|
405
|
-
|
|
414
|
+
logger.always("");
|
|
415
|
+
logger.always(chalk.bold("By Provider:"));
|
|
406
416
|
// Sort by cost descending
|
|
407
417
|
const sortedProviders = [...metrics.costByProvider].sort((a, b) => b.totalCost - a.totalCost);
|
|
408
418
|
for (const provider of sortedProviders) {
|
|
409
419
|
const costStr = formatCost(provider.totalCost);
|
|
410
420
|
const avgCost = formatCost(provider.avgCostPerRequest);
|
|
411
|
-
|
|
412
|
-
|
|
421
|
+
logger.always(` ${chalk.cyan(provider.provider.padEnd(15))} ${costStr}`);
|
|
422
|
+
logger.always(chalk.gray(` ${provider.requestCount} requests, avg ${avgCost}/req`));
|
|
413
423
|
}
|
|
414
424
|
}
|
|
415
425
|
// Cost by model
|
|
416
426
|
if (args.byModel !== false &&
|
|
417
427
|
metrics.costByModel &&
|
|
418
428
|
metrics.costByModel.length > 0) {
|
|
419
|
-
|
|
420
|
-
|
|
429
|
+
logger.always("");
|
|
430
|
+
logger.always(chalk.bold("By Model:"));
|
|
421
431
|
// Sort by cost descending
|
|
422
432
|
const sortedModels = [...metrics.costByModel].sort((a, b) => b.totalCost - a.totalCost);
|
|
423
433
|
for (const model of sortedModels) {
|
|
424
434
|
const costStr = formatCost(model.totalCost);
|
|
425
435
|
const avgCost = formatCost(model.avgCostPerRequest);
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
436
|
+
logger.always(` ${chalk.cyan(model.model)}`);
|
|
437
|
+
logger.always(` Cost: ${costStr}`);
|
|
438
|
+
logger.always(chalk.gray(` ${model.requestCount} requests, avg ${avgCost}/req`));
|
|
439
|
+
logger.always(chalk.gray(` ${model.inputTokens.toLocaleString()} input, ${model.outputTokens.toLocaleString()} output tokens`));
|
|
430
440
|
}
|
|
431
441
|
}
|
|
432
442
|
// No cost data
|
|
433
443
|
if ((!metrics.costByProvider ||
|
|
434
444
|
metrics.costByProvider.length === 0) &&
|
|
435
445
|
(!metrics.costByModel || metrics.costByModel.length === 0)) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
446
|
+
logger.always("");
|
|
447
|
+
logger.always(chalk.gray("No cost data available."));
|
|
448
|
+
logger.always(chalk.gray("Cost tracking is recorded when observability is enabled."));
|
|
439
449
|
}
|
|
440
|
-
|
|
450
|
+
logger.always("");
|
|
441
451
|
}
|
|
442
452
|
}
|
|
443
453
|
catch (error) {
|
|
444
|
-
if (spinner)
|
|
454
|
+
if (spinner) {
|
|
445
455
|
spinner.fail("Failed to get costs");
|
|
456
|
+
}
|
|
446
457
|
logger.error("Error:", error instanceof Error ? error.message : String(error));
|
|
447
458
|
process.exit(1);
|
|
448
459
|
}
|
|
@@ -13,6 +13,32 @@ import type { CommandModule } from "yargs";
|
|
|
13
13
|
import type { Hono } from "hono";
|
|
14
14
|
import type { AccountAllowlist, LoadedProxyConfig, ModelRouterInterface, ProxyGuardArgs, ProxyNeurolinkRuntime, ProxyStartArgs, ProxyStartStrategy, ProxyStatusArgs, ProxyTelemetryArgs, ProxyReadinessState } from "../../lib/types/index.js";
|
|
15
15
|
import { ProxyRuntimeConfigStore } from "../../lib/proxy/runtimeConfig.js";
|
|
16
|
+
/**
|
|
17
|
+
* Best-effort check that a pid actually belongs to a neurolink proxy process,
|
|
18
|
+
* so a stale/recycled supervisor pid is never mistaken for a live supervisor
|
|
19
|
+
* and signalled. Returns false when the process cannot be confirmed as ours.
|
|
20
|
+
*
|
|
21
|
+
* @param expectedStartTimeIso - The `ProxySupervisorState.startTime` (ISO
|
|
22
|
+
* string) recorded when the supervisor we expect at `pid` was launched.
|
|
23
|
+
* When provided, this is cross-checked against `pid`'s actual OS-reported
|
|
24
|
+
* start time (see {@link getProcessStartTime}) — the args match alone
|
|
25
|
+
* ("neurolink" + "proxy" both present) is not airtight, since an
|
|
26
|
+
* unrelated process (e.g. a shell running this very test suite, or a
|
|
27
|
+
* coincidentally-named script) could match it too. A pid recycled by such
|
|
28
|
+
* a process would have a start time far from the recorded supervisor
|
|
29
|
+
* startTime, which this catches.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately fail-open on anything unparseable: `ps -o lstart=` output
|
|
32
|
+
* parsing is inherently a little fragile (locale/format quirks), and the
|
|
33
|
+
* cost of a false "can't confirm" is silently regressing to pre-hardening
|
|
34
|
+
* behavior, while the cost of a false "confirmed mismatch" is leaving a
|
|
35
|
+
* real supervisor running and orphaning its socket during uninstall — the
|
|
36
|
+
* former is the safer failure mode. So this only ever returns false for
|
|
37
|
+
* the startTime check when BOTH timestamps parsed successfully AND their
|
|
38
|
+
* drift exceeds the tolerance; any parse failure is treated as "can't
|
|
39
|
+
* confirm a mismatch" and falls through to the args-only result.
|
|
40
|
+
*/
|
|
41
|
+
export declare function processLooksLikeProxySupervisor(pid: number, expectedStartTimeIso?: string): Promise<boolean>;
|
|
16
42
|
export declare function mapClaudeErrorTypeToStatus(errorType?: string): number;
|
|
17
43
|
export declare function createProxyStartApp(params: {
|
|
18
44
|
neurolink: ProxyNeurolinkRuntime["neurolink"];
|
|
@@ -41,6 +41,14 @@ const _require = createRequire(import.meta.url);
|
|
|
41
41
|
const PROXY_VERSION = packageJson.version;
|
|
42
42
|
const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/observability/manage-local-openobserve.sh", import.meta.url));
|
|
43
43
|
const PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
44
|
+
// Allowed drift between a pid's OS-reported start time and the persisted
|
|
45
|
+
// ProxySupervisorState.startTime before processLooksLikeProxySupervisor
|
|
46
|
+
// treats it as a confident mismatch (recycled pid). Generous on purpose:
|
|
47
|
+
// `supervisorStartedAt` is captured inside runLaunchdProxySupervisor, which
|
|
48
|
+
// runs after Node has already booted and done setup, so it always lags the
|
|
49
|
+
// OS-level fork/exec by a little — a tight tolerance would produce false
|
|
50
|
+
// mismatches on a slow/cold-started machine.
|
|
51
|
+
const PROXY_SUPERVISOR_START_TIME_TOLERANCE_MS = 10_000;
|
|
44
52
|
const PROXY_UPDATE_CONTROL_TOKEN = process.env.NEUROLINK_PROXY_UPDATE_CONTROL_TOKEN?.trim() ||
|
|
45
53
|
crypto.randomUUID();
|
|
46
54
|
// =============================================================================
|
|
@@ -98,23 +106,131 @@ function getProcessStatus(pid) {
|
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
109
|
+
* Read a single `ps -o <field>=` field for `pid`, off the event loop (async
|
|
110
|
+
* `execFile`, not `execFileSync`) and bounded by a short timeout, so a
|
|
111
|
+
* hung/zombie `ps` invocation can never block the event loop or hang the
|
|
112
|
+
* (often uninstall-path) async caller. Shared by {@link getProcessStartTime}
|
|
113
|
+
* and {@link processLooksLikeProxySupervisor}, which both read a single
|
|
114
|
+
* `ps -o <field>=` column for the same pid.
|
|
115
|
+
*
|
|
116
|
+
* Returns null — never throws — on any error, timeout, or empty output, so
|
|
117
|
+
* callers uniformly treat "couldn't read" as "can't confirm either way"
|
|
118
|
+
* rather than distinguishing the failure mode.
|
|
104
119
|
*/
|
|
105
|
-
async function
|
|
120
|
+
async function readPsField(pid, field) {
|
|
106
121
|
try {
|
|
107
|
-
const {
|
|
108
|
-
const
|
|
122
|
+
const { execFile } = await import("node:child_process");
|
|
123
|
+
const { promisify } = await import("node:util");
|
|
124
|
+
const execFileAsync = promisify(execFile);
|
|
125
|
+
const { stdout } = await withTimeout(execFileAsync("ps", ["-p", String(pid), "-o", `${field}=`], {
|
|
109
126
|
encoding: "utf-8",
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return
|
|
127
|
+
}), 2000, `ps -o ${field}= timed out for pid ${pid}`);
|
|
128
|
+
const trimmed = stdout.trim();
|
|
129
|
+
return trimmed || null;
|
|
113
130
|
}
|
|
114
131
|
catch {
|
|
115
|
-
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Best-effort, macOS-only (uninstall is macOS-only) read of `pid`'s
|
|
137
|
+
* OS-reported start time via `ps -o lstart=`, which prints a fixed-width
|
|
138
|
+
* `Www Mmm dd hh:mm:ss yyyy` timestamp — the same shape `Date`'s parser
|
|
139
|
+
* already understands (it's the format `Date#toString()` itself produces
|
|
140
|
+
* modulo the trailing timezone). Returns null (never throws) when `ps`
|
|
141
|
+
* fails or its output doesn't parse, so callers can tell "confirmed
|
|
142
|
+
* mismatch" apart from "couldn't confirm either way".
|
|
143
|
+
*/
|
|
144
|
+
async function getProcessStartTime(pid) {
|
|
145
|
+
const out = await readPsField(pid, "lstart");
|
|
146
|
+
if (!out) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
// `ps -o lstart=` prints the OS start time in the machine's LOCAL timezone
|
|
150
|
+
// with no offset, e.g. "Sun Jul 19 20:12:41 2026". Parse the components and
|
|
151
|
+
// build the Date via the local-time constructor rather than relying on the
|
|
152
|
+
// engine's implementation-defined parsing of non-ISO date strings. The
|
|
153
|
+
// result is the same absolute instant the supervisor persisted as an
|
|
154
|
+
// ISO-UTC string, so the drift comparison against it is timezone-safe.
|
|
155
|
+
const m = out.match(/^\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/);
|
|
156
|
+
if (!m) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const monthIndex = [
|
|
160
|
+
"Jan",
|
|
161
|
+
"Feb",
|
|
162
|
+
"Mar",
|
|
163
|
+
"Apr",
|
|
164
|
+
"May",
|
|
165
|
+
"Jun",
|
|
166
|
+
"Jul",
|
|
167
|
+
"Aug",
|
|
168
|
+
"Sep",
|
|
169
|
+
"Oct",
|
|
170
|
+
"Nov",
|
|
171
|
+
"Dec",
|
|
172
|
+
].indexOf(m[1]);
|
|
173
|
+
if (monthIndex < 0) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const parsed = new Date(Number(m[6]), monthIndex, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]));
|
|
177
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Best-effort check that a pid actually belongs to a neurolink proxy process,
|
|
181
|
+
* so a stale/recycled supervisor pid is never mistaken for a live supervisor
|
|
182
|
+
* and signalled. Returns false when the process cannot be confirmed as ours.
|
|
183
|
+
*
|
|
184
|
+
* @param expectedStartTimeIso - The `ProxySupervisorState.startTime` (ISO
|
|
185
|
+
* string) recorded when the supervisor we expect at `pid` was launched.
|
|
186
|
+
* When provided, this is cross-checked against `pid`'s actual OS-reported
|
|
187
|
+
* start time (see {@link getProcessStartTime}) — the args match alone
|
|
188
|
+
* ("neurolink" + "proxy" both present) is not airtight, since an
|
|
189
|
+
* unrelated process (e.g. a shell running this very test suite, or a
|
|
190
|
+
* coincidentally-named script) could match it too. A pid recycled by such
|
|
191
|
+
* a process would have a start time far from the recorded supervisor
|
|
192
|
+
* startTime, which this catches.
|
|
193
|
+
*
|
|
194
|
+
* Deliberately fail-open on anything unparseable: `ps -o lstart=` output
|
|
195
|
+
* parsing is inherently a little fragile (locale/format quirks), and the
|
|
196
|
+
* cost of a false "can't confirm" is silently regressing to pre-hardening
|
|
197
|
+
* behavior, while the cost of a false "confirmed mismatch" is leaving a
|
|
198
|
+
* real supervisor running and orphaning its socket during uninstall — the
|
|
199
|
+
* former is the safer failure mode. So this only ever returns false for
|
|
200
|
+
* the startTime check when BOTH timestamps parsed successfully AND their
|
|
201
|
+
* drift exceeds the tolerance; any parse failure is treated as "can't
|
|
202
|
+
* confirm a mismatch" and falls through to the args-only result.
|
|
203
|
+
*/
|
|
204
|
+
export async function processLooksLikeProxySupervisor(pid, expectedStartTimeIso) {
|
|
205
|
+
const args = await readPsField(pid, "args");
|
|
206
|
+
if (args === null) {
|
|
207
|
+
// `ps` unavailable, timed out, or the pid vanished — do not signal an
|
|
208
|
+
// unverified pid.
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
// `/neurolink/i` alone is too broad: any process whose args merely
|
|
212
|
+
// *mention* "neurolink" (e.g. a shell running in a directory named
|
|
213
|
+
// "neurolink", an editor with a neurolink file open) would match, and a
|
|
214
|
+
// stale/recycled pid running such a process could then be SIGTERM'd/
|
|
215
|
+
// SIGKILL'd by `ensureSupervisorStoppedBeforeClear` during uninstall.
|
|
216
|
+
// Require the actual proxy-supervisor invocation — both "neurolink" AND
|
|
217
|
+
// the "proxy" subcommand present in args.
|
|
218
|
+
if (!(/neurolink/i.test(args) && /\bproxy\b/i.test(args))) {
|
|
116
219
|
return false;
|
|
117
220
|
}
|
|
221
|
+
if (!expectedStartTimeIso) {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
const expected = new Date(expectedStartTimeIso);
|
|
225
|
+
if (Number.isNaN(expected.getTime())) {
|
|
226
|
+
return true; // recorded startTime itself is unparseable — can't confirm a mismatch
|
|
227
|
+
}
|
|
228
|
+
const actual = await getProcessStartTime(pid);
|
|
229
|
+
if (!actual) {
|
|
230
|
+
return true; // ps -o lstart= unavailable/unparseable — can't confirm a mismatch
|
|
231
|
+
}
|
|
232
|
+
const driftMs = Math.abs(actual.getTime() - expected.getTime());
|
|
233
|
+
return driftMs <= PROXY_SUPERVISOR_START_TIME_TOLERANCE_MS;
|
|
118
234
|
}
|
|
119
235
|
/**
|
|
120
236
|
* Ensure any supervisor recorded in state is actually stopped before its state
|
|
@@ -124,14 +240,17 @@ async function processLooksLikeProxySupervisor(pid) {
|
|
|
124
240
|
* the uninstall instead of silently discarding the record.
|
|
125
241
|
*/
|
|
126
242
|
async function ensureSupervisorStoppedBeforeClear() {
|
|
127
|
-
const
|
|
243
|
+
const supervisorState = loadProxySupervisorState();
|
|
244
|
+
const pid = supervisorState?.pid;
|
|
128
245
|
if (!pid || getProcessStatus(pid) === "not_running") {
|
|
129
246
|
return true;
|
|
130
247
|
}
|
|
131
248
|
// Guard against a stale/recycled pid: only signal a process that still looks
|
|
132
249
|
// like a neurolink proxy supervisor, so uninstall never terminates an
|
|
133
|
-
// unrelated, same-user process that inherited the recorded pid.
|
|
134
|
-
|
|
250
|
+
// unrelated, same-user process that inherited the recorded pid. Also cross-
|
|
251
|
+
// checks the pid's actual OS start time against the recorded
|
|
252
|
+
// supervisorState.startTime (see processLooksLikeProxySupervisor).
|
|
253
|
+
if (!(await processLooksLikeProxySupervisor(pid, supervisorState?.startTime))) {
|
|
135
254
|
return true;
|
|
136
255
|
}
|
|
137
256
|
try {
|