@checkstack/healthcheck-script-backend 0.3.17 → 0.4.0

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.
@@ -2,7 +2,9 @@ import {
2
2
  Versioned,
3
3
  z,
4
4
  configString,
5
- configNumber,
5
+ requestTimeoutMs,
6
+ defaultEsmScriptRunner,
7
+ type EsmScriptRunResult,
6
8
  type HealthCheckRunForAggregation,
7
9
  type CollectorResult,
8
10
  type CollectorStrategy,
@@ -24,166 +26,57 @@ import type { ScriptTransportClient } from "./transport-client";
24
26
  import { extractErrorMessage } from "@checkstack/common";
25
27
 
26
28
  // ============================================================================
27
- // SCRIPT EXECUTION UTILITIES (shared with integration-script-backend pattern)
29
+ // EXECUTOR ADAPTER
28
30
  // ============================================================================
31
+ //
32
+ // The collector keeps its own injectable `InlineScriptExecutor`
33
+ // interface for backwards-compatible test mocks. In production it
34
+ // wraps the shared `EsmScriptRunner` from `@checkstack/backend-api` —
35
+ // the canonical subprocess sandbox shared with the integration-script
36
+ // provider. See `core/backend-api/src/esm-script-runner.ts` for the
37
+ // isolation + cleanup model.
29
38
 
30
39
  /**
31
- * Context available to inline scripts.
40
+ * Shape returned by an inline-script executor. Mirrors
41
+ * `EsmScriptRunResult` from the shared runner, kept as a separate
42
+ * interface so the test surface of this collector doesn't have to
43
+ * import from backend-api just to construct a mock.
32
44
  */
33
- interface ScriptContext {
34
- /** Health check configuration */
35
- config: Record<string, unknown>;
36
- /** Fetch API for HTTP requests */
37
- fetch: typeof fetch;
38
- }
39
-
40
- /**
41
- * Safe console interface for scripts.
42
- */
43
- interface SafeConsole {
44
- log: (...args: unknown[]) => void;
45
- warn: (...args: unknown[]) => void;
46
- error: (...args: unknown[]) => void;
47
- info: (...args: unknown[]) => void;
45
+ export interface InlineScriptExecutionResult {
46
+ result?: unknown;
47
+ error?: string;
48
+ stack?: string;
49
+ stdout: string;
50
+ stderr: string;
51
+ timedOut: boolean;
48
52
  }
49
53
 
50
- /**
51
- * Expected return type from health check scripts.
52
- */
53
- interface ScriptHealthResult {
54
- /** Whether the health check passed */
55
- success: boolean;
56
- /** Optional message describing the result */
57
- message?: string;
58
- /** Optional numeric value for metrics */
59
- value?: number;
54
+ export interface InlineScriptExecutor {
55
+ execute(input: {
56
+ script: string;
57
+ config: Record<string, unknown>;
58
+ timeoutMs: number;
59
+ }): Promise<InlineScriptExecutionResult>;
60
60
  }
61
61
 
62
62
  /**
63
- * Execute an inline script with the given context.
63
+ * Default executor delegates to the shared `EsmScriptRunner`. Wires
64
+ * `globalThis.context = { config }` (the inline-health-check runtime
65
+ * surface) and the virtual `@checkstack/healthcheck` module / global
66
+ * `defineHealthCheck` helper.
64
67
  */
65
- async function executeInlineScript({
66
- script,
67
- context,
68
- safeConsole,
69
- timeoutMs,
70
- }: {
71
- script: string;
72
- context: ScriptContext;
73
- safeConsole: SafeConsole;
74
- timeoutMs: number;
75
- }): Promise<{
76
- result: ScriptHealthResult | undefined;
77
- error?: string;
78
- timedOut: boolean;
79
- }> {
80
- try {
81
- // SECURITY: Execute in isolated Worker to prevent access to process, require, Bun, etc.
82
- const workerCode = `
83
- self.onmessage = async (event) => {
84
- const { script, config } = event.data;
85
- const logs = [];
86
- const safeConsole = {
87
- log: (...args) => logs.push(args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")),
88
- warn: (...args) => logs.push("[WARN] " + args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")),
89
- error: (...args) => logs.push("[ERROR] " + args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")),
90
- info: (...args) => logs.push("[INFO] " + args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")),
91
- };
92
- const context = { config, fetch: globalThis.fetch };
93
- try {
94
- const fn = new Function("context", "console", "fetch",
95
- "return (async () => { " + script + " })();"
96
- );
97
- const result = await fn(context, safeConsole, globalThis.fetch);
98
- self.postMessage({ result, logs });
99
- } catch (error) {
100
- self.postMessage({ error: error.message, logs });
101
- }
102
- };
103
- `;
104
- const blob = new Blob([workerCode], { type: "application/javascript" });
105
- const workerUrl = URL.createObjectURL(blob);
106
- const worker = new Worker(workerUrl);
107
-
108
- const workerResult = await Promise.race([
109
- new Promise<{ result?: unknown; error?: string; logs: string[] }>(
110
- (resolve) => {
111
- worker.addEventListener("message", (event: MessageEvent) =>
112
- resolve(event.data),
113
- );
114
- worker.postMessage({ script, config: context.config });
115
- },
116
- ),
117
- new Promise<never>((_, reject) =>
118
- setTimeout(() => reject(new Error("__TIMEOUT__")), timeoutMs),
119
- ),
120
- ]);
121
-
122
- worker.terminate();
123
- URL.revokeObjectURL(workerUrl);
124
-
125
- // Replay logs into the outer safeConsole
126
- if (workerResult.logs && Array.isArray(workerResult.logs)) {
127
- for (const logLine of workerResult.logs) {
128
- if (logLine.startsWith("[WARN] ")) {
129
- safeConsole.warn(logLine.slice(7));
130
- } else if (logLine.startsWith("[ERROR] ")) {
131
- safeConsole.error(logLine.slice(8));
132
- } else if (logLine.startsWith("[INFO] ")) {
133
- safeConsole.info(logLine.slice(7));
134
- } else {
135
- safeConsole.log(logLine);
136
- }
137
- }
138
- }
139
-
140
- if (workerResult.error) {
141
- throw new Error(workerResult.error);
142
- }
143
-
144
- const { result } = workerResult;
145
-
146
- // Normalize result
147
- if (result === undefined || result === null) {
148
- return { result: { success: true }, timedOut: false };
149
- }
150
-
151
- if (typeof result === "boolean") {
152
- return { result: { success: result }, timedOut: false };
153
- }
154
-
155
- if (typeof result === "object") {
156
- const resultObj = result as Record<string, unknown>;
157
- return {
158
- result: {
159
- success: Boolean(resultObj.success ?? true),
160
- message:
161
- typeof resultObj.message === "string"
162
- ? resultObj.message
163
- : undefined,
164
- value:
165
- typeof resultObj.value === "number" ? resultObj.value : undefined,
166
- },
167
- timedOut: false,
168
- };
169
- }
170
-
171
- return {
172
- result: { success: true, message: String(result) },
173
- timedOut: false,
174
- };
175
- } catch (error) {
176
- const message = extractErrorMessage(error);
177
- if (message === "__TIMEOUT__") {
178
- return {
179
- result: undefined,
180
- error: "Script execution timed out",
181
- timedOut: true,
182
- };
183
- }
184
- return { result: undefined, error: message, timedOut: false };
185
- }
186
- }
68
+ const defaultInlineScriptExecutor: InlineScriptExecutor = {
69
+ async execute({ script, config, timeoutMs }) {
70
+ const res: EsmScriptRunResult = await defaultEsmScriptRunner.run({
71
+ script,
72
+ context: { config },
73
+ timeoutMs,
74
+ helperModuleName: "@checkstack/healthcheck",
75
+ helperFunctionName: "defineHealthCheck",
76
+ });
77
+ return res;
78
+ },
79
+ };
187
80
 
188
81
  // ============================================================================
189
82
  // CONFIGURATION SCHEMA
@@ -193,13 +86,9 @@ const inlineScriptConfigSchema = z.object({
193
86
  script: configString({
194
87
  "x-editor-types": ["typescript"],
195
88
  }).describe(
196
- "TypeScript/JavaScript code to execute. Return { success: boolean, message?: string, value?: number }",
89
+ "TypeScript/JavaScript module. Use `import { ... } from \"node:os\"` to pull in Node built-ins. The recommended pattern is `export default defineHealthCheck({ success, message?, value? })` — `defineHealthCheck` is provided by `@checkstack/healthcheck` and asserts the return shape at the type level. Throwing also signals failure.",
197
90
  ),
198
- timeout: configNumber({})
199
- .min(1000)
200
- .max(60_000)
201
- .default(10_000)
202
- .describe("Maximum execution time in milliseconds"),
91
+ timeout: requestTimeoutMs().describe("Maximum execution time in milliseconds"),
203
92
  });
204
93
 
205
94
  export type InlineScriptConfig = z.infer<typeof inlineScriptConfigSchema>;
@@ -270,37 +159,71 @@ export type InlineScriptAggregatedResult = InferAggregatedResult<
270
159
  typeof inlineScriptAggregatedFields
271
160
  >;
272
161
 
162
+ // ============================================================================
163
+ // RESULT NORMALISATION
164
+ // ============================================================================
165
+
166
+ interface NormalisedScriptResult {
167
+ success: boolean;
168
+ message?: string;
169
+ value?: number;
170
+ }
171
+
172
+ function normaliseScriptReturn(raw: unknown): NormalisedScriptResult {
173
+ if (raw === undefined || raw === null) {
174
+ return { success: true };
175
+ }
176
+ if (typeof raw === "boolean") {
177
+ return { success: raw };
178
+ }
179
+ if (typeof raw === "number") {
180
+ return { success: true, value: raw };
181
+ }
182
+ if (typeof raw === "object") {
183
+ const obj = raw as Record<string, unknown>;
184
+ return {
185
+ success: typeof obj.success === "boolean" ? obj.success : true,
186
+ message: typeof obj.message === "string" ? obj.message : undefined,
187
+ value: typeof obj.value === "number" ? obj.value : undefined,
188
+ };
189
+ }
190
+ return { success: true, message: String(raw) };
191
+ }
192
+
273
193
  // ============================================================================
274
194
  // INLINE SCRIPT COLLECTOR
275
195
  // ============================================================================
276
196
 
277
197
  /**
278
198
  * Inline Script collector for health checks.
279
- * Executes TypeScript/JavaScript code directly and checks the result.
280
- *
281
- * Scripts should return an object with:
282
- * - success: boolean - Whether the check passed
283
- * - message?: string - Optional status message
284
- * - value?: number - Optional numeric value for metrics
285
199
  *
286
- * Scripts have access to:
287
- * - context.config - The collector configuration
288
- * - console.log/warn/error - Logging functions
289
- * - fetch - HTTP client for making requests
200
+ * The user writes a TypeScript/JavaScript ES module. It can `import` from
201
+ * Node built-ins (`node:os`, `node:fs/promises`, `node:child_process`,
202
+ * `node:crypto`, ...), use top-level `await`, and signal its result either
203
+ * via `export default` or for backwards compatibility — a top-level
204
+ * `return X;`. `globalThis.context` exposes `{ config }` for access to the
205
+ * collector configuration.
290
206
  *
291
- * @example
292
- * ```typescript
293
- * // Simple check
294
- * return { success: true, message: "All good!" };
207
+ * Subprocess isolation, env scrubbing, temp-dir lifecycle and result
208
+ * marshalling all live in the shared `EsmScriptRunner` in
209
+ * `@checkstack/backend-api`. This collector is just the schema +
210
+ * result-shape glue.
295
211
  *
296
- * // HTTP health check
297
- * const response = await fetch("https://api.example.com/health");
298
- * return {
299
- * success: response.ok,
300
- * message: `Status: ${response.status}`,
301
- * value: response.status
212
+ * @example Modern (ES module)
213
+ * ```ts
214
+ * import { loadavg } from "node:os";
215
+ * const load = loadavg()[0];
216
+ * export default {
217
+ * success: load < 0.6,
218
+ * message: `Load: ${load.toFixed(2)}`,
219
+ * value: load,
302
220
  * };
303
221
  * ```
222
+ *
223
+ * @example Legacy (still works)
224
+ * ```ts
225
+ * return { success: true, message: "All good!" };
226
+ * ```
304
227
  */
305
228
  export class InlineScriptCollector implements CollectorStrategy<
306
229
  ScriptTransportClient,
@@ -323,6 +246,12 @@ export class InlineScriptCollector implements CollectorStrategy<
323
246
  fields: inlineScriptAggregatedFields,
324
247
  });
325
248
 
249
+ private executor: InlineScriptExecutor;
250
+
251
+ constructor(executor: InlineScriptExecutor = defaultInlineScriptExecutor) {
252
+ this.executor = executor;
253
+ }
254
+
326
255
  async execute({
327
256
  config,
328
257
  }: {
@@ -332,74 +261,68 @@ export class InlineScriptCollector implements CollectorStrategy<
332
261
  }): Promise<CollectorResult<InlineScriptResult>> {
333
262
  const startTime = Date.now();
334
263
 
335
- // Build context for the script
336
- const scriptContext: ScriptContext = {
337
- config: config as unknown as Record<string, unknown>,
338
- fetch,
339
- };
340
-
341
- // Create a safe console that captures logs
342
- const logs: string[] = [];
343
- const safeConsole: SafeConsole = {
344
- log: (...args) => {
345
- logs.push(
346
- args
347
- .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
348
- .join(" "),
349
- );
350
- },
351
- warn: (...args) => {
352
- logs.push(
353
- `[WARN] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
354
- );
355
- },
356
- error: (...args) => {
357
- logs.push(
358
- `[ERROR] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
359
- );
360
- },
361
- info: (...args) => {
362
- logs.push(
363
- `[INFO] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
364
- );
365
- },
366
- };
367
-
368
- // Execute the script
369
- const { result, error, timedOut } = await executeInlineScript({
370
- script: config.script,
371
- context: scriptContext,
372
- safeConsole,
373
- timeoutMs: config.timeout,
374
- });
264
+ let exec: InlineScriptExecutionResult;
265
+ try {
266
+ exec = await this.executor.execute({
267
+ script: config.script,
268
+ config: config as unknown as Record<string, unknown>,
269
+ timeoutMs: config.timeout,
270
+ });
271
+ } catch (error) {
272
+ const executionTimeMs = Date.now() - startTime;
273
+ const message = extractErrorMessage(error);
274
+ return {
275
+ result: {
276
+ success: false,
277
+ message,
278
+ executionTimeMs,
279
+ timedOut: false,
280
+ },
281
+ error: message,
282
+ };
283
+ }
375
284
 
376
285
  const executionTimeMs = Date.now() - startTime;
377
286
 
378
- if (error) {
287
+ if (exec.timedOut) {
379
288
  return {
380
289
  result: {
381
290
  success: false,
382
- message: error,
291
+ message: exec.error ?? "Script execution timed out",
383
292
  executionTimeMs,
384
- timedOut,
293
+ timedOut: true,
385
294
  },
386
- error,
295
+ error: exec.error ?? "Script execution timed out",
387
296
  };
388
297
  }
389
298
 
299
+ if (exec.error !== undefined) {
300
+ return {
301
+ result: {
302
+ success: false,
303
+ message: exec.error,
304
+ executionTimeMs,
305
+ timedOut: false,
306
+ },
307
+ error: exec.error,
308
+ };
309
+ }
310
+
311
+ const normalised = normaliseScriptReturn(exec.result);
312
+ const fallbackMessage =
313
+ exec.stdout.length > 0 ? exec.stdout : undefined;
314
+
390
315
  return {
391
316
  result: {
392
- success: result?.success ?? true,
393
- message:
394
- result?.message ?? (logs.length > 0 ? logs.join("\n") : undefined),
395
- value: result?.value,
317
+ success: normalised.success,
318
+ message: normalised.message ?? fallbackMessage,
319
+ value: normalised.value,
396
320
  executionTimeMs,
397
321
  timedOut: false,
398
322
  },
399
- error:
400
- result?.success === false
401
- ? (result.message ?? "Check failed")
402
- : undefined,
323
+ error: normalised.success
324
+ ? undefined
325
+ : (normalised.message ?? "Check failed"),
403
326
  };
404
327
  }
405
328
 
@@ -3,28 +3,84 @@ import { ScriptHealthCheckStrategy } from "./strategy";
3
3
 
4
4
  describe("ScriptHealthCheckStrategy Security", () => {
5
5
  it("should not leak sensitive environment variables to child process", async () => {
6
- // Set a secret in the current process
7
6
  process.env.TEST_SECRET_KEY = "SUPER_SECRET_KEY_DO_NOT_LEAK";
8
7
 
9
- // Use the default strategy which uses the real Bun.spawn
10
8
  const strategy = new ScriptHealthCheckStrategy();
11
9
  const connectedClient = await strategy.createClient({ timeout: 5000 });
12
10
 
13
11
  const result = await connectedClient.client.exec({
14
- command: "env",
15
- args: [],
12
+ script: "env",
16
13
  timeout: 5000,
17
14
  });
18
15
 
19
16
  connectedClient.close();
20
17
 
21
- // Cleanup before assertions to be safe
22
18
  delete process.env.TEST_SECRET_KEY;
23
19
 
24
20
  expect(result.exitCode).toBe(0);
25
21
  expect(result.stdout).not.toContain("SUPER_SECRET_KEY_DO_NOT_LEAK");
26
- // Ensure we still have some environment variables
27
- // PATH is usually present, but let's check for something else generic or just ensure output is not empty
22
+ // Sanity: at least PATH/HOME should still be forwarded from the whitelist.
28
23
  expect(result.stdout.length).toBeGreaterThan(0);
29
24
  });
25
+
26
+ it("runs a real shell script with pipes, conditionals, and awk", async () => {
27
+ // This was the original regression that motivated switching to `sh -c`:
28
+ // previously the entire shell expression was passed to spawn() as the
29
+ // literal argv[0] and failed with ENOENT. With `sh -c`, the shell parses
30
+ // the whole thing — pipes and awk included.
31
+ const strategy = new ScriptHealthCheckStrategy();
32
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
33
+
34
+ const result = await connectedClient.client.exec({
35
+ script:
36
+ "printf '0.42 0.50 0.60\\n' | awk '{ print $1 }' | awk -v t=0.60 '{ if ($1+0 > t) exit 1; else print \"load_ok=\"$1 }'",
37
+ timeout: 5000,
38
+ });
39
+
40
+ connectedClient.close();
41
+
42
+ expect(result.exitCode).toBe(0);
43
+ expect(result.stdout).toContain("load_ok=0.42");
44
+ expect(result.timedOut).toBe(false);
45
+ });
46
+
47
+ it("the starter template's load-average extraction runs on the current OS", async () => {
48
+ // Regression guard: the previous starter used `awk '{print $1}'
49
+ // /proc/loadavg`, which only exists on Linux. macOS satellites
50
+ // failed with "awk: can't open file /proc/loadavg". The current
51
+ // starter uses `uptime` instead so it runs everywhere we deploy.
52
+ //
53
+ // This test executes the same pipeline the starter uses and just
54
+ // asserts a numeric float comes out. Don't compare to a specific
55
+ // value — load on the test runner is whatever it is.
56
+ const strategy = new ScriptHealthCheckStrategy();
57
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
58
+
59
+ const result = await connectedClient.client.exec({
60
+ script:
61
+ "uptime | sed 's/.*load average[s]*: //' | awk '{ print $1 }' | tr -d ','",
62
+ timeout: 5000,
63
+ });
64
+
65
+ connectedClient.close();
66
+
67
+ expect(result.exitCode).toBe(0);
68
+ expect(result.stdout).toMatch(/^\d+(\.\d+)?$/);
69
+ expect(result.timedOut).toBe(false);
70
+ });
71
+
72
+ it("propagates non-zero exit codes from the user's script", async () => {
73
+ const strategy = new ScriptHealthCheckStrategy();
74
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
75
+
76
+ const result = await connectedClient.client.exec({
77
+ script: "echo 'load too high' && exit 1",
78
+ timeout: 5000,
79
+ });
80
+
81
+ connectedClient.close();
82
+
83
+ expect(result.exitCode).toBe(1);
84
+ expect(result.stdout).toContain("load too high");
85
+ });
30
86
  });
@@ -2,7 +2,6 @@ import { describe, expect, it, mock } from "bun:test";
2
2
  import { ScriptHealthCheckStrategy, ScriptExecutor } from "./strategy";
3
3
 
4
4
  describe("ScriptHealthCheckStrategy", () => {
5
- // Helper to create mock Script executor
6
5
  const createMockExecutor = (
7
6
  config: {
8
7
  exitCode?: number;
@@ -50,8 +49,7 @@ describe("ScriptHealthCheckStrategy", () => {
50
49
  const connectedClient = await strategy.createClient({ timeout: 5000 });
51
50
 
52
51
  const result = await connectedClient.client.exec({
53
- command: "/usr/bin/true",
54
- args: [],
52
+ script: "true",
55
53
  timeout: 5000,
56
54
  });
57
55
 
@@ -68,8 +66,7 @@ describe("ScriptHealthCheckStrategy", () => {
68
66
  const connectedClient = await strategy.createClient({ timeout: 5000 });
69
67
 
70
68
  const result = await connectedClient.client.exec({
71
- command: "/usr/bin/false",
72
- args: [],
69
+ script: "false",
73
70
  timeout: 5000,
74
71
  });
75
72
 
@@ -85,8 +82,7 @@ describe("ScriptHealthCheckStrategy", () => {
85
82
  const connectedClient = await strategy.createClient({ timeout: 5000 });
86
83
 
87
84
  const result = await connectedClient.client.exec({
88
- command: "sleep",
89
- args: ["60"],
85
+ script: "sleep 60",
90
86
  timeout: 1000,
91
87
  });
92
88
 
@@ -102,8 +98,7 @@ describe("ScriptHealthCheckStrategy", () => {
102
98
  const connectedClient = await strategy.createClient({ timeout: 5000 });
103
99
 
104
100
  const result = await connectedClient.client.exec({
105
- command: "nonexistent-command",
106
- args: [],
101
+ script: "no-such-binary-anywhere",
107
102
  timeout: 5000,
108
103
  });
109
104
 
@@ -112,14 +107,13 @@ describe("ScriptHealthCheckStrategy", () => {
112
107
  connectedClient.close();
113
108
  });
114
109
 
115
- it("should pass arguments, cwd, and env to executor", async () => {
110
+ it("should pass script, cwd and env through to executor", async () => {
116
111
  const mockExecutor = createMockExecutor({ exitCode: 0 });
117
112
  const strategy = new ScriptHealthCheckStrategy(mockExecutor);
118
113
  const connectedClient = await strategy.createClient({ timeout: 5000 });
119
114
 
120
115
  await connectedClient.client.exec({
121
- command: "./check.sh",
122
- args: ["--verbose", "--env=prod"],
116
+ script: "./check.sh --verbose --env=prod",
123
117
  cwd: "/opt/scripts",
124
118
  env: { API_KEY: "secret" },
125
119
  timeout: 5000,
@@ -127,8 +121,7 @@ describe("ScriptHealthCheckStrategy", () => {
127
121
 
128
122
  expect(mockExecutor.execute).toHaveBeenCalledWith(
129
123
  expect.objectContaining({
130
- command: "./check.sh",
131
- args: ["--verbose", "--env=prod"],
124
+ script: "./check.sh --verbose --env=prod",
132
125
  cwd: "/opt/scripts",
133
126
  env: { API_KEY: "secret" },
134
127
  }),