@checkstack/healthcheck-script-backend 0.3.16 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,129 @@
1
1
  # @checkstack/healthcheck-script-backend
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a06b899: Overhaul shell + inline-script health checks with real shell semantics, real ESM execution, and upstream Node/Bun IntelliSense.
8
+
9
+ **BREAKING CHANGES**
10
+
11
+ - **Shell collector** — the `Execute Script` collector now takes a single `script` string instead of `{ command, args }`. Existing configs are auto-migrated to v2: `command` + `args` are joined with POSIX single-quote escaping into the new `script` field, so behaviour is preserved. Custom UIs that hard-coded `command`/`args` field names need to switch to `script`.
12
+ - **Inline collector** — scripts are now executed as real ES modules in a Bun subprocess (was: `new Function()` inside a Web Worker). The legacy `return X;` style still works (it's auto-wrapped in an async IIFE), but mixed scripts that `import` _and_ `return` at the top level need to use `export default` for their result.
13
+
14
+ **FIXES**
15
+
16
+ - Shell scripts containing pipes, redirects, `awk`, command substitution, conditionals etc. no longer fail with `ENOENT`. The collector now runs through `sh -c <script>` instead of passing the full expression as `Bun.spawn`'s argv[0]. This was the original `awk … failed with ENOENT` regression.
17
+ - Inline scripts can now `import { loadavg } from "node:os"` (and any other `node:*` or `bun` import). They could not before, because the executor wrapped user code inside `new Function(...)` and ran it in a Web Worker that had no Node module access; the wrapper also made top-level `import` syntactically invalid (`Unexpected token '{'`).
18
+ - Healthcheck editor fields no longer reset while you're editing. The page was re-running its form-state init `useEffect` on every refetch of the configuration query — and that query is invalidated on every realtime `HEALTH_CHECK_RUN_COMPLETED` signal across the platform, so in-progress edits got wiped within seconds. Replaced the naive `useEffect([existingConfig])` with a new `useInitOnceForKey` hook from `@checkstack/ui` that initialises the form only on first load per healthcheck id and ignores background refetches. The hook's decision logic is a pure function (`shouldInitForKey`) and is unit-tested in `useInitOnceForKey.test.ts`.
19
+ - Switching between healthcheck collectors no longer mis-applies the previous collector's tokenizer / language service to the new editor. `MultiTypeEditorField` was reusing the same React instance across collector switches (same `key="script"` in both `DynamicForm` renders) and `selectedType` was initialised from `useState` only once on first mount. After a shell→typescript switch the new collector's TS content rendered through the shell branch (no TS highlighting, no IntelliSense); the reverse direction tokenised shell content through TS and surfaced nonsense errors like `2304 "Cannot find name 'and'"` on shell comments. Now a `useEffect` re-derives `selectedType` whenever `editorTypes` changes to a set that doesn't contain the current selection.
20
+ - Monaco workers are now bundled locally via Vite `?worker` imports and wired up through `MonacoEnvironment.getWorker` in a new `monacoWorkers.ts` module. The default `@monaco-editor/loader` CDN path silently failed CORS on worker scripts in some browsers, leaving Monaco's TS service with only the generic `editorWorkerService` — which is enough for tokenizer-only languages like shell but breaks TypeScript's semantic features entirely. Same module configures the TS service singleton (compiler options, eager-model-sync, diagnostics-options-ignore-1108) at module load instead of inside per-editor `onMount`, so the service starts pre-configured regardless of which language opens first. Migrated from the deprecated `monaco.languages.typescript.*` path to `monaco.typescript.*` (the old path is marked `{ deprecated: true }` in monaco-editor 0.55).
21
+ - `defineIntegration` / `defineHealthCheck` callback parameters are now typed against the schema. Previously the virtual module declared them as `(ctx: unknown) => …`, so writing `defineIntegration(async (context) => { console.log(context.event.eventId) })` produced `'context' is of type 'unknown'. (18046)`. The result type and the shared `IntegrationScriptContext` / `HealthCheckScriptContext` interfaces are now generated together in `scriptContext.ts`, so both the function-arg form and the ambient `declare const context` reference the same schema-typed shape.
22
+ - The shell starter template no longer uses Linux-only `/proc/loadavg` (which fails on macOS satellites with `awk: can't open file /proc/loadavg`). It now reads the 1-minute load average via `uptime` and parses both the Linux (`load average: 0.00, 0.01, 0.05`) and macOS (`load averages: 0.45 0.55 0.65`) output formats with a portable `sed`/`awk`/`tr` pipeline.
23
+ - Starter-template seeding is now self-healing. `DynamicForm`'s schema-defaults `useEffect` fires AFTER child seed effects in React's child-before-parent order, so the previous one-shot seed got clobbered back to `""` by the defaults call on first mount and never re-fired. Replaced the `[]`-deps effect with a two-effect pattern: an observer that latches `hasSeededRef = true` the first time `value` is observed non-empty, and a seed effect that keeps re-installing the starter while the latch is open. Once the seed sticks the latch closes; subsequent edits and realtime refetches don't re-trigger.
24
+
25
+ **NEW**
26
+
27
+ - The Monaco editor for inline scripts now mounts the real upstream `@types/node` + `bun-types` declarations as a virtual filesystem (lazy-loaded as its own JS chunk), so IntelliSense covers the full Node/Bun stdlib, the `Bun` global, `process.env`, `Buffer`, etc. DOM types are deliberately excluded so suggestions stay focused on the backend surface. `context.config` is typed from the collector's own JSON Schema.
28
+ - New `healthcheckScriptContext` / `integrationScriptContext` helpers (exported from `@checkstack/ui`) build a complete editor bundle in one call: TS declarations (`context.config` / `context.event.payload` + the virtual `@checkstack/healthcheck` / `@checkstack/integration` result-type modules), starter templates per language, and the shell env-var list (with platform-injected `EVENT_ID` / `DELIVERY_ID` / `PAYLOAD_*` for integrations). Both call sites — `CollectorSection.tsx` and `CreateSubscriptionDialog.tsx` — were rewired to use them, fixing a long-standing wiring gap where IntelliSense for injected values silently never reached the editor.
29
+ - Inline scripts can now `import { defineHealthCheck } from "@checkstack/healthcheck"` (or `defineIntegration` for integrations) for a typed return-shape assertion. The editor catches `{ success: "yes" }` as a type error against `HealthCheckScriptResult`. The runtime is just an identity function — the collector rewrites the import to a sibling helper file in the temp dir before executing.
30
+ - Shell editors now autocomplete env-vars after `$` and `${`. The completion list is supplied by `healthcheckScriptContext` (safe-vars whitelist) and `integrationScriptContext` (whitelist + `EVENT_ID` etc. + `PAYLOAD_*` flattened from the event's payload schema). The matcher is pure and unit-tested in `shellEnvVarMatcher.test.ts` so regex regressions are caught locally.
31
+ - Empty editor fields are now seeded with a working starter template per language (inline TS uses `defineHealthCheck`, inline shell does the `awk` load-average check, integration TS shows `defineIntegration` with `context.event`, integration shell lists the `$EVENT_ID` / `$PAYLOAD_*` env vars). Users see a runnable example instead of a blank canvas; once they edit, we leave their content alone.
32
+ - Hardened concurrency + cleanup model documented and tested: each invocation gets its own `mkdtemp` directory + UUID result marker; the `finally` block clears the timeout handle, kills any surviving subprocess (idempotent), and removes the temp directory on success, throw, _and_ timeout. New `concurrency.test.ts` proves 20 parallel inline scripts don't cross wires and that the temp-dir count returns to baseline after throws and timeouts.
33
+
34
+ **TESTING**
35
+
36
+ Tight unit tests added so changes to the editor surface don't need smoke testing:
37
+
38
+ - `scriptContext.test.ts` — 18 tests covering the generated type declarations (including explicit regression guards for `defineIntegration` / `defineHealthCheck` callback params being typed against the shared context interface rather than `unknown`), starter templates (including a guard that the shell starter doesn't depend on Linux-only `/proc/loadavg`), shell env-vars for both healthcheck + integration flavours, plus the schema-flattening utility.
39
+ - `shellEnvVarMatcher.test.ts` — 12 tests covering the bare `$` / braced `${` / partial-name / case-insensitive matching logic that powers Monaco's shell completion.
40
+ - `inline-script-normaliser.test.ts` — 13 tests covering the legacy `return X;` → IIFE wrap path, the ESM-passthrough path, and the `@checkstack/healthcheck` import rewriter.
41
+ - `inline-script-collector.test.ts` — 18 tests including ones that actually execute a script importing `defineHealthCheck` (named-import form) AND using the global `defineHealthCheck` (no import) to prove both code paths resolve at runtime.
42
+ - `concurrency.test.ts` — 4 tests proving 20 parallel runs don't collide and that the temp-dir count returns to baseline after success, throw, and timeout.
43
+ - `useInitOnceForKey.test.ts` — 10 tests proving the healthcheck-editor form state isn't reset when react-query refetches in the background (the original "fields reset while I'm typing" regression).
44
+ - `starterTemplateSelector.test.ts` — 7 tests for the pure decision function powering empty-field seeding.
45
+ - `security.test.ts` — added an integration test that actually executes the portable load-average pipeline through `Bun.spawn` on the current OS, catching `/proc/loadavg`-style regressions at CI time on macOS runners.
46
+
47
+ **SECURITY**
48
+
49
+ - Same env-var whitelist as before (`PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `TMPDIR`, `HOSTNAME`, `SHELL`). Backend secrets in the satellite process's environment remain invisible to user scripts.
50
+
51
+ See `docs/src/content/docs/backend/script-healthchecks.md` for the full user-facing guide.
52
+
53
+ ### Patch Changes
54
+
55
+ - a06b899: Dead-code audit cleanup and a small platform of shared notification helpers.
56
+
57
+ **Removed (dead code)**
58
+
59
+ - `core/backend/src/plugin-manager/deregistration-guard.ts` deleted. The exported `assertCanDeregister()` was never called and was a less-complete version of the dependents+isUninstallable checks already done inline by `previewUninstallOriginator` / `uninstallOriginator` in `plugin-manager-orchestrator.ts`.
60
+ - `createMockQueueFactory` deprecated alias removed from `@checkstack/test-utils-backend`. Use `createMockQueueManager` directly.
61
+
62
+ **New shared helpers**
63
+
64
+ - `@checkstack/backend-api` now exports `requestTimeoutMs()` — a Zod field builder for outbound HTTP request timeouts (1s..60s, default 10s). Replaces hand-rolled `configNumber({}).min(1000).max(60_000).default(10_000)` in `integration-webhook-backend`, `integration-script-backend`, and `healthcheck-script-backend`'s inline collector.
65
+ - `@checkstack/notification-common` now exports `SubjectStatusSchema` / `SubjectStatus`, mirroring the existing `ImportanceSchema`.
66
+ - `@checkstack/notification-backend` now exports:
67
+ - `SUBJECT_STATUS_EMOJI` / `IMPORTANCE_EMOJI` — the shared status / importance emoji maps that Discord, Slack, Teams, Webex and Telegram previously each redefined inline.
68
+ - `postJson(opts)` — a timeout-bounded `fetch` wrapper that handles non-2xx logging and error mapping for webhook-style POSTs. Returns `{ ok: true, response } | { ok: false, error }`.
69
+
70
+ **Migrated to shared helpers**
71
+
72
+ - Discord, Slack, Gotify, Pushover notification backends now use `postJson`. Outer try/catch + per-plugin error mapping deleted (~140 LOC).
73
+ - Discord, Slack, Teams, Telegram, Webex notification backends now use `IMPORTANCE_EMOJI`. Discord, Slack, Teams use `SUBJECT_STATUS_EMOJI`.
74
+ - Teams, Webex, Backstage, Telegram kept their inline fetch/Bot logic: their error strings surface server response bodies to operators, or the transport isn't raw `fetch` (Telegram uses `grammy`'s `Bot`).
75
+
76
+ **API surface tightening**
77
+
78
+ - Per-plugin test-only re-exports in 6 notification backends (Pushover, Gotify, Backstage, Slack, Discord, Teams) and the `CertificateInfo` interface in `healthcheck-tls-backend/strategy.ts` are now JSDoc-tagged `@internal`. No behaviour change; signals that downstream consumers must not depend on them.
79
+
80
+ - a06b899: Extract shared `EsmScriptRunner` + `ShellScriptRunner` utilities, fix HIGH-severity privilege amplification in the integration TS provider, and harden the integration shell setupGuide example.
81
+
82
+ **SECURITY FIX (HIGH)**
83
+
84
+ The integration TS provider (`@checkstack/integration-script-backend` → `scriptProvider`) previously executed user scripts via `new Function(script)` in the satellite's main V8 isolate. A user with `integrationAccess.manage` could read `globalThis.process.env` directly (`DATABASE_URL`, `JWT_SECRET`, queue credentials, signing keys, …) and exfiltrate them through `result.id` — which round-trips into `delivery_logs.externalId` and is readable via the `getDeliveryLog` ORPC procedure. The same permission grants no legitimate API to those secrets; this was a privilege amplification.
85
+
86
+ The provider now runs user scripts in a fresh Bun subprocess (matching the healthcheck inline-script collector model). The subprocess receives only a curated `SAFE_ENV_VARS` whitelist (`PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `TMPDIR`, `HOSTNAME`, `SHELL`) — backend secrets are no longer visible to user code. Filesystem reads, network calls (`fetch`), and the rest of the Node/Bun standard library continue to work, just in an isolated process.
87
+
88
+ **BREAKING CHANGE (`@checkstack/integration-script-backend`)**
89
+
90
+ User scripts can no longer read the satellite process's environment variables (`process.env.DATABASE_URL` etc. return `undefined`). Scripts that legitimately need configuration should accept it via the provider's `script` field input, not by introspecting the host environment. The full Node/Bun stdlib remains available; only the env scrub is new.
91
+
92
+ **REFACTOR — new shared utilities in `@checkstack/backend-api`**
93
+
94
+ Both the healthcheck and integration plugins had near-identical inline implementations of "run a user script in a subprocess sandbox" (ESM path) and "run a user shell script through `sh -c`" (shell path). These are now canonical, single-source-of-truth utilities:
95
+
96
+ - **`defaultEsmScriptRunner.run({ script, context, timeoutMs, helperModuleName?, helperFunctionName? })`** — writes the user module to a fresh `mkdtemp` directory along with a generated runner module, spawns a Bun subprocess with `pickSafeEnv()`, parses the result back through a UUID-tagged stderr marker, and tears everything down in `finally` (`clearTimeout` + `proc.kill()` + recursive `rm`). The optional `helperModuleName` / `helperFunctionName` pair drops a sibling `_helpers.mjs` file and rewrites `import { <fn> } from "<module>"` to point at it (this is the trick that makes `@checkstack/healthcheck` / `@checkstack/integration` resolve at runtime even though they're not real npm packages).
97
+ - **`defaultShellScriptRunner.run({ script, timeoutMs, cwd?, env? })`** — invokes `sh -c <script>` via `Bun.spawn` with `SAFE_ENV_VARS` (user-supplied `env` merged on top), `Promise.race` timeout with `proc.kill()` on expiry, and the same `clearTimeout` + `proc.kill()` cleanup in `finally`.
98
+
99
+ Both runners expose `EsmScriptRunner` / `ShellScriptRunner` interfaces so tests can inject mocks without touching the spawn path. The four call sites (`plugins/healthcheck-script-backend/src/inline-script-collector.ts`, `strategy.ts` and `plugins/integration-script-backend/src/provider.ts`, `shell-provider.ts`) collapse from full inline implementations to ~8-line adapters.
100
+
101
+ **FIXES**
102
+
103
+ - Integration shell provider's `setupGuide` example replaced the unsafe `curl -d "{\"title\": \"$PAYLOAD_TITLE\"}"` JSON interpolation with a `jq -n --arg title "$PAYLOAD_TITLE" '{title: $title}'` pattern. The previous example demonstrated a shell-injection vulnerability whenever event payload values contained shell-special or JSON-special characters (which they can, since payloads come from other plugins / events / GitOps reconciles).
104
+ - The shared shell runner adds `clearTimeout` + idempotent `proc?.kill()` in `finally`, fixing a leaked event-loop timer in the integration shell provider's previous inline implementation.
105
+
106
+ **TESTS**
107
+
108
+ - New `core/backend-api/src/esm-script-runner.test.ts` covering `normaliseUserScript` + `rewriteHelperImports` across both healthcheck and integration helper-module names, including regex-metacharacter escape coverage.
109
+ - The plugin-local `inline-script-normaliser.test.ts` was deleted; the same coverage (plus more) lives at the canonical location with the utility.
110
+ - Integration TS provider console-logging tests updated: in the subprocess model, `console.warn` and `console.error` both write to stderr (Bun matches Node), so the provider forwards every stderr line to `logger.error`. `console.log({…})` uses Bun's native `util.inspect` format rather than `JSON.stringify`, so the JSON-logging test now asserts on substring presence instead of strict serialisation.
111
+
112
+ 2047 tests pass, lint + typecheck clean.
113
+
114
+ - Updated dependencies [a06b899]
115
+ - Updated dependencies [a06b899]
116
+ - @checkstack/backend-api@0.16.0
117
+ - @checkstack/healthcheck-common@1.1.1
118
+
119
+ ## 0.3.17
120
+
121
+ ### Patch Changes
122
+
123
+ - Updated dependencies [1909a61]
124
+ - Updated dependencies [b33fb4d]
125
+ - @checkstack/backend-api@0.15.3
126
+
3
127
  ## 0.3.16
4
128
 
5
129
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-script-backend",
3
- "version": "0.3.16",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -14,15 +14,15 @@
14
14
  "pack": "bunx @checkstack/scripts plugin-pack"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.15.1",
18
- "@checkstack/common": "0.9.0",
19
- "@checkstack/healthcheck-common": "1.0.2"
17
+ "@checkstack/backend-api": "0.15.3",
18
+ "@checkstack/common": "0.10.0",
19
+ "@checkstack/healthcheck-common": "1.1.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "^1.0.0",
23
23
  "typescript": "^5.0.0",
24
24
  "@checkstack/tsconfig": "0.0.7",
25
- "@checkstack/scripts": "0.3.1"
25
+ "@checkstack/scripts": "0.3.2"
26
26
  },
27
27
  "description": "Checkstack healthcheck-script-backend plugin",
28
28
  "author": {
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { readdir } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { InlineScriptCollector } from "./inline-script-collector";
5
+ import { ScriptHealthCheckStrategy } from "./strategy";
6
+ import type { ScriptTransportClient } from "./transport-client";
7
+
8
+ /**
9
+ * Concurrency + cleanup guarantees.
10
+ *
11
+ * These tests are slower than the unit tests (each spawns 20+ subprocesses)
12
+ * but they're the only place where we exercise the actual isolation
13
+ * properties — that two parallel inline scripts can't read each other's
14
+ * temp files, and that the temp-dir count returns to baseline once
15
+ * everything completes (i.e. nothing leaks on success, failure, or timeout).
16
+ */
17
+
18
+ const PARALLELISM = 20;
19
+ const TMP_PREFIX = "checkstack-script-";
20
+
21
+ async function countLeakedTmpDirs(): Promise<number> {
22
+ const entries = await readdir(tmpdir());
23
+ return entries.filter((name) => name.startsWith(TMP_PREFIX)).length;
24
+ }
25
+
26
+ const mockClient: ScriptTransportClient = {
27
+ exec: async () => ({
28
+ exitCode: 0,
29
+ stdout: "",
30
+ stderr: "",
31
+ timedOut: false,
32
+ }),
33
+ };
34
+
35
+ describe("inline-script concurrency", () => {
36
+ it("isolates parallel runs — each sees its own `context.config`", async () => {
37
+ // Each script reads its own config.value via `context.config.value` and
38
+ // returns it. If isolation is broken (two scripts share a tmp dir, env,
39
+ // or the same runner file by accident), we'd see crossed values here.
40
+ const collector = new InlineScriptCollector();
41
+
42
+ const runs = Array.from({ length: PARALLELISM }, (_, i) => i);
43
+
44
+ const results = await Promise.all(
45
+ runs.map(async (i) => {
46
+ const res = await collector.execute({
47
+ // The config carries the index; the script returns it via `value`.
48
+ config: {
49
+ // @ts-expect-error — we deliberately pass an extra prop the
50
+ // schema doesn't know about; the runner serialises the whole
51
+ // config object as `context.config`, so the script can read it.
52
+ value: i,
53
+ script: `
54
+ // @ts-ignore — context is injected at runtime
55
+ export default { success: true, value: context.config.value };
56
+ `,
57
+ timeout: 30_000,
58
+ },
59
+ client: mockClient,
60
+ pluginId: "concurrency-test",
61
+ });
62
+ return res.result.value;
63
+ }),
64
+ );
65
+
66
+ // Each run should have returned its own index — no crossed wires.
67
+ expect(results.sort((a, b) => (a ?? 0) - (b ?? 0))).toEqual(runs);
68
+ }, 60_000);
69
+
70
+ it("cleans up temp dirs even when scripts throw", async () => {
71
+ const collector = new InlineScriptCollector();
72
+ const before = await countLeakedTmpDirs();
73
+
74
+ await Promise.all(
75
+ Array.from({ length: PARALLELISM }, () =>
76
+ collector.execute({
77
+ config: {
78
+ // Mix of throwing and successful scripts to exercise both paths.
79
+ script: `
80
+ if (Math.random() > 0.5) throw new Error("boom");
81
+ export default { success: true };
82
+ `,
83
+ timeout: 30_000,
84
+ },
85
+ client: mockClient,
86
+ pluginId: "concurrency-test",
87
+ }),
88
+ ),
89
+ );
90
+
91
+ const after = await countLeakedTmpDirs();
92
+ expect(after).toBe(before);
93
+ }, 60_000);
94
+
95
+ it("cleans up temp dirs when scripts time out", async () => {
96
+ const collector = new InlineScriptCollector();
97
+ const before = await countLeakedTmpDirs();
98
+
99
+ await Promise.all(
100
+ Array.from({ length: 5 }, () =>
101
+ collector.execute({
102
+ config: {
103
+ script: `await new Promise((r) => setTimeout(r, 60_000));`,
104
+ timeout: 1000,
105
+ },
106
+ client: mockClient,
107
+ pluginId: "concurrency-test",
108
+ }),
109
+ ),
110
+ );
111
+
112
+ // Give the OS a moment to actually reap the killed subprocesses before
113
+ // we check; rm runs synchronously after each .execute() resolves, but
114
+ // the kernel can be slow to drop fds on macOS.
115
+ await new Promise((r) => setTimeout(r, 100));
116
+
117
+ const after = await countLeakedTmpDirs();
118
+ expect(after).toBe(before);
119
+ }, 30_000);
120
+ });
121
+
122
+ describe("shell-script concurrency", () => {
123
+ it("isolates parallel runs — each sees its own arguments via env", async () => {
124
+ // No temp file involvement on the shell path, so this test just
125
+ // exercises the spawn-per-call guarantee. Each script echoes the
126
+ // value of its own MY_RUN env var; we check no crossed values.
127
+ const strategy = new ScriptHealthCheckStrategy();
128
+ const connected = await strategy.createClient({ timeout: 5000 });
129
+
130
+ const runs = Array.from({ length: PARALLELISM }, (_, i) => i);
131
+
132
+ const results = await Promise.all(
133
+ runs.map((i) =>
134
+ connected.client.exec({
135
+ script: 'printf "%s" "$MY_RUN"',
136
+ env: { MY_RUN: String(i) },
137
+ timeout: 5000,
138
+ }),
139
+ ),
140
+ );
141
+
142
+ connected.close();
143
+
144
+ const seen = results.map((r) => Number.parseInt(r.stdout, 10));
145
+ expect(seen.sort((a, b) => a - b)).toEqual(runs);
146
+ }, 60_000);
147
+ });
@@ -28,8 +28,13 @@ describe("ExecuteCollector", () => {
28
28
  const collector = new ExecuteCollector();
29
29
  const client = createMockClient({ exitCode: 0, stdout: "Hello World" });
30
30
 
31
+ const config: ExecuteConfig = {
32
+ script: "echo 'Hello World'",
33
+ timeout: 5000,
34
+ };
35
+
31
36
  const result = await collector.execute({
32
- config: { command: "echo", args: ["Hello", "World"], timeout: 5000 },
37
+ config,
33
38
  client,
34
39
  pluginId: "test",
35
40
  });
@@ -50,7 +55,7 @@ describe("ExecuteCollector", () => {
50
55
  });
51
56
 
52
57
  const result = await collector.execute({
53
- config: { command: "nonexistent", args: [], timeout: 5000 },
58
+ config: { script: "exit 1", timeout: 5000 },
54
59
  client,
55
60
  pluginId: "test",
56
61
  });
@@ -65,7 +70,7 @@ describe("ExecuteCollector", () => {
65
70
  const client = createMockClient({ timedOut: true, exitCode: -1 });
66
71
 
67
72
  const result = await collector.execute({
68
- config: { command: "sleep", args: ["999"], timeout: 100 },
73
+ config: { script: "sleep 999", timeout: 100 },
69
74
  client,
70
75
  pluginId: "test",
71
76
  });
@@ -74,14 +79,13 @@ describe("ExecuteCollector", () => {
74
79
  expect(result.result.success).toBe(false);
75
80
  });
76
81
 
77
- it("should pass correct parameters to client", async () => {
82
+ it("should pass the script, cwd and env through to the client", async () => {
78
83
  const collector = new ExecuteCollector();
79
84
  const client = createMockClient();
80
85
 
81
86
  await collector.execute({
82
87
  config: {
83
- command: "/usr/bin/check",
84
- args: ["--verbose"],
88
+ script: "awk '/load/ {print $1}' /proc/loadavg | head -1",
85
89
  cwd: "/tmp",
86
90
  env: { MY_VAR: "value" },
87
91
  timeout: 3000,
@@ -91,8 +95,7 @@ describe("ExecuteCollector", () => {
91
95
  });
92
96
 
93
97
  expect(client.exec).toHaveBeenCalledWith({
94
- command: "/usr/bin/check",
95
- args: ["--verbose"],
98
+ script: "awk '/load/ {print $1}' /proc/loadavg | head -1",
96
99
  cwd: "/tmp",
97
100
  env: { MY_VAR: "value" },
98
101
  timeout: 3000,
@@ -100,6 +103,41 @@ describe("ExecuteCollector", () => {
100
103
  });
101
104
  });
102
105
 
106
+ describe("migration", () => {
107
+ it("collapses legacy {command, args} into a single shell script", async () => {
108
+ const collector = new ExecuteCollector();
109
+ const v1 = {
110
+ command: "echo",
111
+ args: ["Hello", "World"],
112
+ timeout: 5000,
113
+ };
114
+
115
+ // Versioned.parse() applies migrations transparently when the stored
116
+ // value declares an older version. Mirror what storage does:
117
+ const migrated = await collector.config.parse({ version: 1, data: v1 });
118
+
119
+ expect(migrated.script).toBe("echo Hello World");
120
+ expect(migrated.timeout).toBe(5000);
121
+ });
122
+
123
+ it("quotes args that contain shell metacharacters", async () => {
124
+ const collector = new ExecuteCollector();
125
+ const v1 = {
126
+ command: "/bin/sh",
127
+ args: ["-c", "echo 'hi there'"],
128
+ timeout: 5000,
129
+ };
130
+
131
+ const migrated = await collector.config.parse({ version: 1, data: v1 });
132
+
133
+ // The script should re-quote args so re-execution via `sh -c` is safe.
134
+ expect(migrated.script).toContain("/bin/sh");
135
+ expect(migrated.script).toContain("-c");
136
+ // The embedded quotes must be preserved (single-quoted form).
137
+ expect(migrated.script).toMatch(/echo/);
138
+ });
139
+ });
140
+
103
141
  describe("mergeResult", () => {
104
142
  it("should calculate average execution time and success rate", () => {
105
143
  const collector = new ExecuteCollector();
@@ -190,7 +228,7 @@ describe("ExecuteCollector", () => {
190
228
  const collector = new ExecuteCollector();
191
229
 
192
230
  expect(collector.id).toBe("execute");
193
- expect(collector.displayName).toBe("Execute Script");
231
+ expect(collector.displayName).toBe("Shell Script");
194
232
  expect(collector.allowMultiple).toBe(true);
195
233
  expect(collector.supportedPlugins).toHaveLength(1);
196
234
  });
@@ -22,27 +22,59 @@ import { pluginMetadata } from "./plugin-metadata";
22
22
  import type { ScriptTransportClient } from "./transport-client";
23
23
 
24
24
  // ============================================================================
25
- // CONFIGURATION SCHEMA
25
+ // LEGACY CONFIG (v1) — kept for migration
26
26
  // ============================================================================
27
27
 
28
- const executeConfigSchema = z.object({
29
- command: configString({
28
+ interface ExecuteConfigV1 {
29
+ command: string;
30
+ args: string[];
31
+ cwd?: string;
32
+ env?: Record<string, string>;
33
+ timeout: number;
34
+ }
35
+
36
+ /**
37
+ * Quote an arg for the shell when migrating a v1 command+args pair into a
38
+ * single v2 script. Bare identifiers are passed through untouched; anything
39
+ * else gets single-quoted with embedded quotes escaped.
40
+ */
41
+ function shellQuote(arg: string): string {
42
+ if (arg === "") return "''";
43
+ if (/^[A-Za-z0-9_\-./=:@%+]+$/.test(arg)) return arg;
44
+ // POSIX single-quote escape: close, emit an escaped quote, reopen.
45
+ return `'${arg.replaceAll("'", String.raw`'\''`)}'`;
46
+ }
47
+
48
+ // ============================================================================
49
+ // CONFIGURATION SCHEMA (v2)
50
+ // ============================================================================
51
+
52
+ const executeConfigSchemaV2 = z.object({
53
+ script: configString({
30
54
  "x-editor-types": ["shell"],
31
- }).describe("Shell command or script to execute"),
32
- args: z.array(z.string()).default([]).describe("Command arguments"),
33
- cwd: z.string().optional().describe("Working directory"),
55
+ }).describe(
56
+ "Shell script source. Executed via `sh -c`, so pipes, redirects, `if`/`for`/`while`, variable expansion, command substitution etc. all work. Exit non-zero to fail the check.",
57
+ ),
58
+ cwd: z
59
+ .string()
60
+ .optional()
61
+ .describe("Working directory for the script (defaults to the satellite's CWD)"),
34
62
  env: z
35
63
  .record(z.string(), z.string())
36
64
  .optional()
37
- .describe("Environment variables"),
65
+ .describe(
66
+ "Extra environment variables to expose to the script. Merged on top of the safe-vars whitelist (PATH, HOME, ...).",
67
+ ),
38
68
  timeout: z
39
69
  .number()
70
+ .int()
40
71
  .min(100)
72
+ .max(300_000)
41
73
  .default(30_000)
42
- .describe("Timeout in milliseconds"),
74
+ .describe("Maximum execution time in milliseconds"),
43
75
  });
44
76
 
45
- export type ExecuteConfig = z.infer<typeof executeConfigSchema>;
77
+ export type ExecuteConfig = z.infer<typeof executeConfigSchemaV2>;
46
78
 
47
79
  // ============================================================================
48
80
  // RESULT SCHEMAS
@@ -120,8 +152,19 @@ export type ExecuteAggregatedResult = InferAggregatedResult<
120
152
  // ============================================================================
121
153
 
122
154
  /**
123
- * Built-in Script execute collector.
124
- * Runs commands and checks results.
155
+ * Shell-script execute collector.
156
+ *
157
+ * The user provides a shell script as a single string; we run it through
158
+ * `sh -c`. That means anything POSIX `sh` accepts works as expected:
159
+ *
160
+ * ```sh
161
+ * # Fail when 1-minute load average exceeds 0.60 (portable: Linux + macOS).
162
+ * load=$(uptime | sed 's/.*load average[s]*: //' | awk '{print $1}' | tr -d ',')
163
+ * awk -v l="$load" 'BEGIN { exit (l+0 > 0.60) ? 1 : 0 }'
164
+ * ```
165
+ *
166
+ * Exit code 0 = healthy, anything else = unhealthy. stdout / stderr are
167
+ * captured and reported with the run.
125
168
  */
126
169
  export class ExecuteCollector implements CollectorStrategy<
127
170
  ScriptTransportClient,
@@ -130,14 +173,36 @@ export class ExecuteCollector implements CollectorStrategy<
130
173
  ExecuteAggregatedResult
131
174
  > {
132
175
  id = "execute";
133
- displayName = "Execute Script";
134
- description = "Execute a command or script and check the result";
176
+ displayName = "Shell Script";
177
+ description = "Run a shell script and treat exit code 0 as healthy";
135
178
 
136
179
  supportedPlugins = [pluginMetadata];
137
180
 
138
181
  allowMultiple = true;
139
182
 
140
- config = new Versioned({ version: 1, schema: executeConfigSchema });
183
+ config = new Versioned({
184
+ version: 2,
185
+ schema: executeConfigSchemaV2,
186
+ migrations: [
187
+ {
188
+ fromVersion: 1,
189
+ toVersion: 2,
190
+ description:
191
+ "Collapse {command, args} into a single shell script executed via `sh -c`",
192
+ migrate: (data: ExecuteConfigV1): ExecuteConfig => {
193
+ const parts = [data.command, ...(data.args ?? [])].map((arg) =>
194
+ shellQuote(arg),
195
+ );
196
+ return {
197
+ script: parts.join(" "),
198
+ cwd: data.cwd,
199
+ env: data.env,
200
+ timeout: data.timeout,
201
+ };
202
+ },
203
+ },
204
+ ],
205
+ });
141
206
  result = new Versioned({ version: 1, schema: executeResultSchema });
142
207
  aggregatedResult = new VersionedAggregated({
143
208
  version: 1,
@@ -155,8 +220,7 @@ export class ExecuteCollector implements CollectorStrategy<
155
220
  const startTime = Date.now();
156
221
 
157
222
  const response = await client.exec({
158
- command: config.command,
159
- args: config.args,
223
+ script: config.script,
160
224
  cwd: config.cwd,
161
225
  env: config.env,
162
226
  timeout: config.timeout,