@checkstack/healthcheck-script-backend 0.4.2 → 0.6.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,178 @@
1
1
  # @checkstack/healthcheck-script-backend
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 270ef29: Extend in-UI script testing to health-check collectors, and add
8
+ load-from-run replay for automation script tests.
9
+
10
+ - Health-check collectors: a new `testCollectorScript` RPC runs the
11
+ inline-script (TypeScript) collector and the shell `script` collector
12
+ against an editable, auto-seeded sample context using the same
13
+ sandboxed runner the real collector uses. Surfaces beneath the
14
+ collector script fields in the collector editor (both marked
15
+ `x-script-testable`). Gated by `healthcheck.configuration.manage`.
16
+ - Automation replay: a new `getRunScopeForReplay` RPC reconstructs an
17
+ editable test context from a real run (trigger + persisted artifacts,
18
+ plus the durable scope snapshot when the run is still in-flight), and
19
+ the script-test panel gains a "Load from run" picker that seeds the
20
+ sample context from a past run.
21
+
22
+ Note: health-check executions do not persist the script / config /
23
+ check / system that produced a result, so there is no health-check
24
+ replay - auto-seed is the only context source for collector tests. This
25
+ is by design; see the feature plan.
26
+
27
+ - 270ef29: Activate npm packages in script execution: thread the managed
28
+ `resolutionRoot` into every user-script call site so an allowlisted package
29
+ can actually be `import`ed.
30
+
31
+ - `@checkstack/backend-api`: the ESM runner now always writes a per-run
32
+ `bunfig.toml` with `[install] auto = "disable"` and runs with that dir as
33
+ CWD. Without this Bun silently auto-installs any imported package from the
34
+ registry (verified), defeating the allowlist; with it, imports resolve
35
+ only against the reconciled `current/node_modules` (when a `resolutionRoot`
36
+ is set) and otherwise fail fast.
37
+ - `@checkstack/script-packages-backend`: `resolveResolutionRoot` /
38
+ `resolveResolutionRootFromStore` / `resolveResolutionRootForHost` decide a
39
+ host's resolution-root status (`none` / `ready` / `notReady`) from the
40
+ local `<store>/current`.
41
+ - `run_script` (integration-script-backend), the inline-script collector
42
+ (healthcheck-script-backend, core + satellite), and the in-UI `testScript`
43
+ / `testCollectorScript` endpoints all resolve the root per run and pass it
44
+ to the runner; `run_script` surfaces a clear "npm packages not ready"
45
+ error when configured-but-unsynced. Shell paths are unaffected (no module
46
+ resolution).
47
+
48
+ An opt-in end-to-end test (`CHECKSTACK_E2E_NETWORK=1`) proves an allowlisted
49
+ package imports successfully through the real `run_script` action execute
50
+ path, with non-network degradation tests running always.
51
+
52
+ BREAKING CHANGES: `@checkstack/backend-api`'s `defaultEsmScriptRunner` now
53
+ always disables Bun auto-install for the user subprocess. A script that
54
+ previously relied on Bun silently fetching an un-vendored package from the
55
+ registry at import time will now fail to resolve it. This is intentional -
56
+ package availability is governed by the admin allowlist - but any caller
57
+ depending on the old implicit auto-install behavior must add the package to
58
+ the allowlist instead. The new `EsmScriptRunOptions.resolutionRoot` field is
59
+ optional and additive (defaults to today's `os.tmpdir()` behavior when
60
+ unset), so the runner API itself is source-compatible.
61
+
62
+ - 270ef29: Secrets platform Phase 3: just-in-time secret delivery to satellites + source-side masking, and central-execution injection for healthcheck collectors.
63
+
64
+ - New satellite WS messages `request_run_secrets` / `run_secrets`: just
65
+ before a satellite runs a collector that declares a `secretEnv`, it asks
66
+ core for that collector's resolved env; core resolves ONLY the secrets the
67
+ collector's OWN persisted assignment declares (least-privilege — the
68
+ satellite cannot choose) and replies with the env map (or a clear error).
69
+ The satellite injects it memory-only for the run and drops it on
70
+ completion. Secrets never ride the persisted assignment and never touch
71
+ disk.
72
+ - Source-side masking: the satellite runs `maskSecrets` over the collector's
73
+ stdout/stderr/result/error using the run's delivered values BEFORE the
74
+ result leaves the satellite (defense in depth).
75
+ - `CollectorStrategy.execute` gains an optional `secretEnv`. The
76
+ inline-script and shell collectors inject it into the runner
77
+ (`process.env` / `$VAR`) and mask the values out of their output.
78
+ - Healthcheck collectors running centrally (the queue executor) also resolve
79
+ - inject `secretEnv` via `secretResolverRef`, closing the gap where a
80
+ centrally-run secretEnv collector got no secrets. A missing required
81
+ secret fails the run clearly in all paths.
82
+
83
+ ### Patch Changes
84
+
85
+ - Updated dependencies [270ef29]
86
+ - Updated dependencies [270ef29]
87
+ - Updated dependencies [270ef29]
88
+ - Updated dependencies [270ef29]
89
+ - Updated dependencies [270ef29]
90
+ - Updated dependencies [270ef29]
91
+ - Updated dependencies [b995afb]
92
+ - Updated dependencies [b995afb]
93
+ - Updated dependencies [b995afb]
94
+ - Updated dependencies [270ef29]
95
+ - Updated dependencies [270ef29]
96
+ - Updated dependencies [270ef29]
97
+ - Updated dependencies [270ef29]
98
+ - Updated dependencies [270ef29]
99
+ - Updated dependencies [b995afb]
100
+ - Updated dependencies [b995afb]
101
+ - Updated dependencies [270ef29]
102
+ - Updated dependencies [270ef29]
103
+ - Updated dependencies [270ef29]
104
+ - Updated dependencies [b995afb]
105
+ - Updated dependencies [270ef29]
106
+ - Updated dependencies [b995afb]
107
+ - Updated dependencies [270ef29]
108
+ - Updated dependencies [270ef29]
109
+ - Updated dependencies [b995afb]
110
+ - Updated dependencies [270ef29]
111
+ - Updated dependencies [270ef29]
112
+ - Updated dependencies [270ef29]
113
+ - Updated dependencies [270ef29]
114
+ - Updated dependencies [270ef29]
115
+ - Updated dependencies [b995afb]
116
+ - @checkstack/backend-api@0.19.0
117
+ - @checkstack/healthcheck-common@1.4.0
118
+ - @checkstack/script-packages-backend@0.2.0
119
+ - @checkstack/secrets-common@0.1.0
120
+
121
+ ## 0.5.0
122
+
123
+ ### Minor Changes
124
+
125
+ - 35bc682: feat(healthcheck): expose check + system run-context to script collectors
126
+
127
+ Script health checks can now read which check and system a run is for.
128
+ Previously shell scripts got only a curated env whitelist and inline
129
+ scripts only `context.config`, so a script had no built-in way to know
130
+ its own check name or the system it was checking.
131
+
132
+ - `@checkstack/backend-api`: new `CollectorRunContext` type
133
+ (`{ check: { id, name, intervalSeconds }, system: { id, name } }`) and
134
+ an optional `runContext` param on `CollectorStrategy.execute`. Optional,
135
+ so existing collector implementations are unaffected.
136
+ - Shell-script collector: injects reserved `CHECKSTACK_CHECK_ID`,
137
+ `CHECKSTACK_CHECK_NAME`, `CHECKSTACK_CHECK_INTERVAL_SECONDS`,
138
+ `CHECKSTACK_SYSTEM_ID`, `CHECKSTACK_SYSTEM_NAME` env vars (user-supplied
139
+ `env` still wins on collision).
140
+ - Inline-script collector: exposes `context.check` and `context.system`
141
+ alongside `context.config`; the inline-script editor now types them for
142
+ autocomplete.
143
+ - Shell editors (health-check collectors and automation shell actions) now
144
+ also suggest the user's own `env` (JSON) keys as `$NAME` completions, via
145
+ the new exported `customShellEnvVars` helper. Keys that aren't valid shell
146
+ identifiers are omitted.
147
+ - Fix: the Typefox `CodeEditor` captured a stale `onChange` at editor start,
148
+ so editing one `DynamicForm` field reverted sibling fields changed since
149
+ mount (e.g. typing in a shell `script` field wiped an unsaved `env` value,
150
+ or deleted a sibling automation action added after mount). The change
151
+ handler now routes through a ref to the current `onChange`.
152
+ - Fix: focusing a JSON editor threw "LanguageStatusService.addStatus is not
153
+ supported" because the standalone service set omitted `ILanguageStatusService`.
154
+ That one service is now registered via `serviceOverrides`.
155
+ - Fix: the automation trigger card nested a `<Badge>` (a `<div>`) inside a
156
+ `<p>`, producing a `validateDOMNesting` warning. Switched the wrapper to a
157
+ `<div>`.
158
+ - Local runs (`queue-executor`) and satellite runs both populate the
159
+ context. `SatelliteAssignment` (and the `getAssignmentsForSatellite`
160
+ RPC output) gained optional `configName` / `systemName` so the metadata
161
+ reaches satellite-side execution; `HealthCheckService` resolves the
162
+ system name via the catalog client.
163
+
164
+ BREAKING CHANGE: `createHealthCheckRouter` now requires a `catalogClient`
165
+ option (used to resolve system names for satellite assignments). Update
166
+ call sites to pass the catalog RPC client.
167
+
168
+ ### Patch Changes
169
+
170
+ - Updated dependencies [6d52276]
171
+ - Updated dependencies [35bc682]
172
+ - @checkstack/common@0.12.0
173
+ - @checkstack/backend-api@0.18.0
174
+ - @checkstack/healthcheck-common@1.3.0
175
+
3
176
  ## 0.4.2
4
177
 
5
178
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-script-backend",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -14,15 +14,17 @@
14
14
  "pack": "bunx @checkstack/scripts plugin-pack"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.17.0",
18
- "@checkstack/common": "0.11.0",
19
- "@checkstack/healthcheck-common": "1.1.2"
17
+ "@checkstack/backend-api": "0.18.0",
18
+ "@checkstack/common": "0.12.0",
19
+ "@checkstack/healthcheck-common": "1.3.0",
20
+ "@checkstack/script-packages-backend": "0.1.0",
21
+ "@checkstack/secrets-common": "0.0.1"
20
22
  },
21
23
  "devDependencies": {
22
24
  "@types/bun": "^1.0.0",
23
25
  "typescript": "^5.0.0",
24
26
  "@checkstack/tsconfig": "0.0.7",
25
- "@checkstack/scripts": "0.3.3"
27
+ "@checkstack/scripts": "0.3.4"
26
28
  },
27
29
  "description": "Checkstack healthcheck-script-backend plugin",
28
30
  "author": {
@@ -101,6 +101,66 @@ describe("ExecuteCollector", () => {
101
101
  timeout: 3000,
102
102
  });
103
103
  });
104
+
105
+ it("injects run-context metadata into the script env", async () => {
106
+ const collector = new ExecuteCollector();
107
+ const client = createMockClient();
108
+
109
+ await collector.execute({
110
+ config: { script: "echo hi", timeout: 3000 },
111
+ client,
112
+ pluginId: "test",
113
+ runContext: {
114
+ check: { id: "check-1", name: "CPU load", intervalSeconds: 60 },
115
+ system: { id: "system-9", name: "web-01" },
116
+ },
117
+ });
118
+
119
+ const call = (client.exec as ReturnType<typeof mock>).mock.calls[0][0];
120
+ expect(call.env.CHECKSTACK_CHECK_ID).toBe("check-1");
121
+ expect(call.env.CHECKSTACK_CHECK_NAME).toBe("CPU load");
122
+ expect(call.env.CHECKSTACK_CHECK_INTERVAL_SECONDS).toBe("60");
123
+ expect(call.env.CHECKSTACK_SYSTEM_ID).toBe("system-9");
124
+ expect(call.env.CHECKSTACK_SYSTEM_NAME).toBe("web-01");
125
+ });
126
+
127
+ it("lets a user config.env key win over a metadata key on collision", async () => {
128
+ const collector = new ExecuteCollector();
129
+ const client = createMockClient();
130
+
131
+ await collector.execute({
132
+ config: {
133
+ script: "echo hi",
134
+ env: { CHECKSTACK_CHECK_NAME: "user override" },
135
+ timeout: 3000,
136
+ },
137
+ client,
138
+ pluginId: "test",
139
+ runContext: {
140
+ check: { id: "check-1", name: "metadata name", intervalSeconds: 60 },
141
+ system: { id: "system-9", name: "web-01" },
142
+ },
143
+ });
144
+
145
+ const call = (client.exec as ReturnType<typeof mock>).mock.calls[0][0];
146
+ expect(call.env.CHECKSTACK_CHECK_NAME).toBe("user override");
147
+ // Non-colliding metadata keys are still present.
148
+ expect(call.env.CHECKSTACK_SYSTEM_ID).toBe("system-9");
149
+ });
150
+
151
+ it("leaves env unchanged when no runContext is supplied (back-compat)", async () => {
152
+ const collector = new ExecuteCollector();
153
+ const client = createMockClient();
154
+
155
+ await collector.execute({
156
+ config: { script: "echo hi", env: { MY_VAR: "value" }, timeout: 3000 },
157
+ client,
158
+ pluginId: "test",
159
+ });
160
+
161
+ const call = (client.exec as ReturnType<typeof mock>).mock.calls[0][0];
162
+ expect(call.env).toEqual({ MY_VAR: "value" });
163
+ });
104
164
  });
105
165
 
106
166
  describe("migration", () => {
@@ -234,3 +294,71 @@ describe("ExecuteCollector", () => {
234
294
  });
235
295
  });
236
296
  });
297
+
298
+ describe("ExecuteCollector secret env injection + source-side masking", () => {
299
+ // Capture the env the collector passes to the transport client. Typed
300
+ // so we can assert without indexing the loosely-typed mock.calls tuple.
301
+ const makeRecordingClient = (
302
+ response: { stdout: string; stderr: string },
303
+ ): {
304
+ client: ScriptTransportClient;
305
+ getEnv: () => Record<string, string> | undefined;
306
+ } => {
307
+ let capturedEnv: Record<string, string> | undefined;
308
+ return {
309
+ getEnv: () => capturedEnv,
310
+ client: {
311
+ exec: async (input) => {
312
+ capturedEnv = input.env;
313
+ return {
314
+ exitCode: 0,
315
+ stdout: response.stdout,
316
+ stderr: response.stderr,
317
+ timedOut: false,
318
+ };
319
+ },
320
+ },
321
+ };
322
+ };
323
+
324
+ it("injects the delivered secretEnv into the shell exec and masks it from output", async () => {
325
+ const { client, getEnv } = makeRecordingClient({
326
+ stdout: "value=sh-secret-987",
327
+ stderr: "warn sh-secret-987",
328
+ });
329
+ const collector = new ExecuteCollector();
330
+
331
+ const result = await collector.execute({
332
+ config: {
333
+ script: "echo value=$TOKEN",
334
+ timeout: 5000,
335
+ secretEnv: { TOKEN: "${{ secrets.tok }}" },
336
+ },
337
+ client,
338
+ pluginId: "test",
339
+ secretEnv: { TOKEN: "sh-secret-987" },
340
+ });
341
+
342
+ // The exec received the injected env.
343
+ expect(getEnv()?.TOKEN).toBe("sh-secret-987");
344
+ // The secret is redacted from stdout/stderr (source-side).
345
+ expect(result.result.stdout).toBe("value=****");
346
+ expect(result.result.stderr).toBe("warn ****");
347
+ expect(JSON.stringify(result)).not.toContain("sh-secret-987");
348
+ });
349
+
350
+ it("does not inject or mask when no secretEnv is delivered", async () => {
351
+ const { client, getEnv } = makeRecordingClient({
352
+ stdout: "no secrets",
353
+ stderr: "",
354
+ });
355
+ const collector = new ExecuteCollector();
356
+ const result = await collector.execute({
357
+ config: { script: "echo hi", timeout: 5000 },
358
+ client,
359
+ pluginId: "test",
360
+ });
361
+ expect(getEnv()?.TOKEN).toBeUndefined();
362
+ expect(result.result.stdout).toBe("no secrets");
363
+ });
364
+ });
@@ -2,6 +2,7 @@ import {
2
2
  Versioned,
3
3
  z,
4
4
  configString,
5
+ withConfigMeta,
5
6
  type HealthCheckRunForAggregation,
6
7
  type CollectorResult,
7
8
  type CollectorStrategy,
@@ -11,6 +12,7 @@ import {
11
12
  aggregatedAverage,
12
13
  aggregatedRate,
13
14
  type InferAggregatedResult,
15
+ type CollectorRunContext,
14
16
  } from "@checkstack/backend-api";
15
17
  import {
16
18
  healthResultNumber,
@@ -20,6 +22,39 @@ import {
20
22
  } from "@checkstack/healthcheck-common";
21
23
  import { pluginMetadata } from "./plugin-metadata";
22
24
  import type { ScriptTransportClient } from "./transport-client";
25
+ import {
26
+ secretEnvMappingSchema,
27
+ maskScriptRunOutput,
28
+ } from "@checkstack/secrets-common";
29
+
30
+ // ============================================================================
31
+ // RUN-CONTEXT ENV INJECTION
32
+ // ============================================================================
33
+ //
34
+ // Reserved env var names exposing curated run-context metadata to the
35
+ // shell script. These mirror the automation `CHECKSTACK_` shell
36
+ // convention; intentionally NOT imported from automation-common to keep
37
+ // this plugin's dependency surface minimal.
38
+
39
+ const CHECKSTACK_CHECK_ID = "CHECKSTACK_CHECK_ID";
40
+ const CHECKSTACK_CHECK_NAME = "CHECKSTACK_CHECK_NAME";
41
+ const CHECKSTACK_CHECK_INTERVAL_SECONDS = "CHECKSTACK_CHECK_INTERVAL_SECONDS";
42
+ const CHECKSTACK_SYSTEM_ID = "CHECKSTACK_SYSTEM_ID";
43
+ const CHECKSTACK_SYSTEM_NAME = "CHECKSTACK_SYSTEM_NAME";
44
+
45
+ /**
46
+ * Map curated run-context metadata to the reserved `CHECKSTACK_*` env
47
+ * vars exposed to the shell script.
48
+ */
49
+ function runContextEnv(ctx: CollectorRunContext): Record<string, string> {
50
+ return {
51
+ [CHECKSTACK_CHECK_ID]: ctx.check.id,
52
+ [CHECKSTACK_CHECK_NAME]: ctx.check.name,
53
+ [CHECKSTACK_CHECK_INTERVAL_SECONDS]: String(ctx.check.intervalSeconds),
54
+ [CHECKSTACK_SYSTEM_ID]: ctx.system.id,
55
+ [CHECKSTACK_SYSTEM_NAME]: ctx.system.name,
56
+ };
57
+ }
23
58
 
24
59
  // ============================================================================
25
60
  // LEGACY CONFIG (v1) — kept for migration
@@ -52,6 +87,7 @@ function shellQuote(arg: string): string {
52
87
  const executeConfigSchemaV2 = z.object({
53
88
  script: configString({
54
89
  "x-editor-types": ["shell"],
90
+ "x-script-testable": true,
55
91
  }).describe(
56
92
  "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
93
  ),
@@ -65,6 +101,11 @@ const executeConfigSchemaV2 = z.object({
65
101
  .describe(
66
102
  "Extra environment variables to expose to the script. Merged on top of the safe-vars whitelist (PATH, HOME, ...).",
67
103
  ),
104
+ secretEnv: withConfigMeta(secretEnvMappingSchema, { "x-secret-env": true })
105
+ .optional()
106
+ .describe(
107
+ 'Secret → env mapping, e.g. { "API_TOKEN": "${{ secrets.token }}" }. Only the named secrets are resolved and injected for this run (read via process.env.API_TOKEN / $API_TOKEN); on a satellite they are delivered just-in-time over the encrypted channel, never persisted. Values are masked out of the collector output.',
108
+ ),
68
109
  timeout: z
69
110
  .number()
70
111
  .int()
@@ -212,34 +253,61 @@ export class ExecuteCollector implements CollectorStrategy<
212
253
  async execute({
213
254
  config,
214
255
  client,
256
+ runContext,
257
+ secretEnv,
215
258
  }: {
216
259
  config: ExecuteConfig;
217
260
  client: ScriptTransportClient;
218
261
  pluginId: string;
262
+ runContext?: CollectorRunContext;
263
+ secretEnv?: Record<string, string>;
219
264
  }): Promise<CollectorResult<ExecuteResult>> {
220
265
  const startTime = Date.now();
221
266
 
267
+ // Merge run-context metadata under the user-supplied env, then the
268
+ // resolved secret env on top (a declared secret env name wins, matching
269
+ // the central action's layering). User `config.env` still wins over the
270
+ // run-context vars.
271
+ const env = {
272
+ ...(runContext ? runContextEnv(runContext) : {}),
273
+ ...config.env,
274
+ ...secretEnv,
275
+ };
276
+
222
277
  const response = await client.exec({
223
278
  script: config.script,
224
279
  cwd: config.cwd,
225
- env: config.env,
280
+ env,
226
281
  timeout: config.timeout,
227
282
  });
228
283
 
284
+ // Source-side masking: redact the delivered secret values from the
285
+ // captured output BEFORE it leaves the satellite (core masks again on
286
+ // receipt). A script echoing a secret it was given is masked here.
287
+ const maskValues = Object.values(secretEnv ?? {});
288
+ const masked = maskScriptRunOutput({
289
+ output: {
290
+ stdout: response.stdout,
291
+ stderr: response.stderr,
292
+ error: response.error,
293
+ },
294
+ values: maskValues,
295
+ });
296
+
229
297
  const executionTimeMs = Date.now() - startTime;
230
298
  const success = response.exitCode === 0 && !response.timedOut;
231
299
 
232
300
  return {
233
301
  result: {
234
302
  exitCode: response.exitCode,
235
- stdout: response.stdout,
236
- stderr: response.stderr,
303
+ stdout: masked.stdout,
304
+ stderr: masked.stderr,
237
305
  executionTimeMs,
238
306
  success,
239
307
  timedOut: response.timedOut,
240
308
  },
241
309
  error:
242
- response.error ??
310
+ masked.error ??
243
311
  (success ? undefined : `Exit code: ${response.exitCode}`),
244
312
  };
245
313
  }
package/src/index.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
2
+ import {
3
+ resolveResolutionRootFromStore,
4
+ resolveScriptPackagesDir,
5
+ } from "@checkstack/script-packages-backend";
2
6
  import { ScriptHealthCheckStrategy } from "./strategy";
3
7
  import { pluginMetadata } from "./plugin-metadata";
4
8
  import { ExecuteCollector } from "./execute-collector";
5
- import { InlineScriptCollector } from "./inline-script-collector";
9
+ import {
10
+ InlineScriptCollector,
11
+ defaultInlineScriptExecutor,
12
+ } from "./inline-script-collector";
6
13
 
7
14
  export default createBackendPlugin({
8
15
  metadata: pluginMetadata,
@@ -18,7 +25,17 @@ export default createBackendPlugin({
18
25
  const strategy = new ScriptHealthCheckStrategy();
19
26
  healthCheckRegistry.register(strategy);
20
27
  collectorRegistry.register(new ExecuteCollector());
21
- collectorRegistry.register(new InlineScriptCollector());
28
+ // The inline TS collector resolves the managed npm-package tree from
29
+ // the local store - identical on core and satellites (same
30
+ // `<dataDir>/script-packages/current` convention). No RPC needed, so
31
+ // it works in the satellite runtime too. Execution safety is
32
+ // guaranteed by the runner (auto-install disabled); this just points
33
+ // it at the synced tree when present.
34
+ collectorRegistry.register(
35
+ new InlineScriptCollector(defaultInlineScriptExecutor, () =>
36
+ resolveResolutionRootFromStore(resolveScriptPackagesDir()),
37
+ ),
38
+ );
22
39
  },
23
40
  });
24
41
  },
@@ -2,6 +2,7 @@ import { describe, it, expect } from "bun:test";
2
2
  import {
3
3
  InlineScriptCollector,
4
4
  type InlineScriptConfig,
5
+ type InlineScriptExecutor,
5
6
  } from "./inline-script-collector";
6
7
  import type { ScriptTransportClient } from "./transport-client";
7
8
 
@@ -340,6 +341,49 @@ describe("InlineScriptCollector", () => {
340
341
  });
341
342
  });
342
343
 
344
+ describe("context.runContext", () => {
345
+ it("exposes run-context metadata as `context.check` / `context.system`", async () => {
346
+ const config = createConfig({
347
+ script: `
348
+ // @ts-ignore — context is injected by the runner
349
+ export default { success: true, message: \`\${context.system.name}:\${context.check.id}\` };
350
+ `,
351
+ });
352
+
353
+ const result = await collector.execute({
354
+ config,
355
+ client: mockClient,
356
+ pluginId: "script",
357
+ runContext: {
358
+ check: { id: "check-7", name: "CPU load", intervalSeconds: 30 },
359
+ system: { id: "system-3", name: "web-02" },
360
+ },
361
+ });
362
+
363
+ expect(result.result.success).toBe(true);
364
+ expect(result.result.message).toBe("web-02:check-7");
365
+ });
366
+
367
+ it("still exposes `context.config` and does not crash when runContext is absent (back-compat)", async () => {
368
+ const config = createConfig({
369
+ script: `
370
+ // @ts-ignore — context is injected by the runner
371
+ export default { success: true, value: context.config.timeout, message: typeof context.system };
372
+ `,
373
+ });
374
+
375
+ const result = await collector.execute({
376
+ config,
377
+ client: mockClient,
378
+ pluginId: "script",
379
+ });
380
+
381
+ expect(result.result.success).toBe(true);
382
+ expect(result.result.value).toBe(10_000);
383
+ expect(result.result.message).toBe("undefined");
384
+ });
385
+ });
386
+
343
387
  describe("mergeResult", () => {
344
388
  it("aggregates execution time and success rate", () => {
345
389
  const run1 = {
@@ -361,3 +405,55 @@ describe("InlineScriptCollector", () => {
361
405
  });
362
406
  });
363
407
  });
408
+
409
+ describe("InlineScriptCollector secret env injection + source-side masking", () => {
410
+ it("injects the delivered secretEnv into the executor and masks it from output", async () => {
411
+ const calls: Array<{ secretEnv?: Record<string, string> }> = [];
412
+ const mockExecutor: InlineScriptExecutor = {
413
+ execute: async (input) => {
414
+ calls.push({ secretEnv: input.secretEnv });
415
+ // Simulate a script that echoes its injected secret.
416
+ return {
417
+ result: { success: true, message: "token is gh_realSecret123" },
418
+ stdout: "logged gh_realSecret123",
419
+ stderr: "",
420
+ timedOut: false,
421
+ };
422
+ },
423
+ };
424
+ const collector = new InlineScriptCollector(mockExecutor);
425
+
426
+ const result = await collector.execute({
427
+ config: createConfig({
428
+ secretEnv: { API_TOKEN: "${{ secrets.tok }}" },
429
+ }),
430
+ client: mockClient,
431
+ pluginId: "script",
432
+ secretEnv: { API_TOKEN: "gh_realSecret123" },
433
+ });
434
+
435
+ // The executor received the injected env.
436
+ expect(calls[0]?.secretEnv).toEqual({ API_TOKEN: "gh_realSecret123" });
437
+ // The secret is redacted from the result message (source-side).
438
+ expect(result.result.message).toBe("token is ****");
439
+ expect(JSON.stringify(result)).not.toContain("gh_realSecret123");
440
+ });
441
+
442
+ it("does not mask anything when no secretEnv is delivered", async () => {
443
+ const mockExecutor: InlineScriptExecutor = {
444
+ execute: async () => ({
445
+ result: { success: true, message: "nothing secret" },
446
+ stdout: "plain output",
447
+ stderr: "",
448
+ timedOut: false,
449
+ }),
450
+ };
451
+ const collector = new InlineScriptCollector(mockExecutor);
452
+ const result = await collector.execute({
453
+ config: createConfig(),
454
+ client: mockClient,
455
+ pluginId: "script",
456
+ });
457
+ expect(result.result.message).toBe("nothing secret");
458
+ });
459
+ });
@@ -2,6 +2,7 @@ import {
2
2
  Versioned,
3
3
  z,
4
4
  configString,
5
+ withConfigMeta,
5
6
  requestTimeoutMs,
6
7
  defaultEsmScriptRunner,
7
8
  type EsmScriptRunResult,
@@ -14,6 +15,7 @@ import {
14
15
  aggregatedAverage,
15
16
  aggregatedRate,
16
17
  type InferAggregatedResult,
18
+ type CollectorRunContext,
17
19
  } from "@checkstack/backend-api";
18
20
  import {
19
21
  healthResultNumber,
@@ -24,6 +26,11 @@ import {
24
26
  import { pluginMetadata } from "./plugin-metadata";
25
27
  import type { ScriptTransportClient } from "./transport-client";
26
28
  import { extractErrorMessage } from "@checkstack/common";
29
+ import {
30
+ secretEnvMappingSchema,
31
+ maskScriptRunOutput,
32
+ } from "@checkstack/secrets-common";
33
+ import type { ResolutionRootStatus } from "@checkstack/script-packages-backend";
27
34
 
28
35
  // ============================================================================
29
36
  // EXECUTOR ADAPTER
@@ -56,23 +63,45 @@ export interface InlineScriptExecutor {
56
63
  script: string;
57
64
  config: Record<string, unknown>;
58
65
  timeoutMs: number;
66
+ runContext?: CollectorRunContext;
67
+ /** Managed npm-package resolution root for this run, if ready. */
68
+ resolutionRoot?: string;
69
+ /** Resolved secret env injected into the runner for this run only. */
70
+ secretEnv?: Record<string, string>;
59
71
  }): Promise<InlineScriptExecutionResult>;
60
72
  }
61
73
 
62
74
  /**
63
75
  * 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.
76
+ * `globalThis.context = { config, check?, system? }` (the inline
77
+ * health-check runtime surface) and the virtual `@checkstack/healthcheck`
78
+ * module / global `defineHealthCheck` helper.
67
79
  */
68
- const defaultInlineScriptExecutor: InlineScriptExecutor = {
69
- async execute({ script, config, timeoutMs }) {
80
+ export const defaultInlineScriptExecutor: InlineScriptExecutor = {
81
+ async execute({
82
+ script,
83
+ config,
84
+ timeoutMs,
85
+ runContext,
86
+ resolutionRoot,
87
+ secretEnv,
88
+ }) {
70
89
  const res: EsmScriptRunResult = await defaultEsmScriptRunner.run({
71
90
  script,
72
- context: { config },
91
+ context: {
92
+ config,
93
+ ...(runContext
94
+ ? { check: runContext.check, system: runContext.system }
95
+ : {}),
96
+ },
73
97
  timeoutMs,
74
98
  helperModuleName: "@checkstack/healthcheck",
75
99
  helperFunctionName: "defineHealthCheck",
100
+ ...(resolutionRoot ? { resolutionRoot } : {}),
101
+ // Inject the resolved secrets as process.env for THIS run only.
102
+ ...(secretEnv && Object.keys(secretEnv).length > 0
103
+ ? { env: secretEnv }
104
+ : {}),
76
105
  });
77
106
  return res;
78
107
  },
@@ -85,9 +114,15 @@ const defaultInlineScriptExecutor: InlineScriptExecutor = {
85
114
  const inlineScriptConfigSchema = z.object({
86
115
  script: configString({
87
116
  "x-editor-types": ["typescript"],
117
+ "x-script-testable": true,
88
118
  }).describe(
89
119
  "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.",
90
120
  ),
121
+ secretEnv: withConfigMeta(secretEnvMappingSchema, { "x-secret-env": true })
122
+ .optional()
123
+ .describe(
124
+ 'Secret → env mapping, e.g. { "API_TOKEN": "${{ secrets.token }}" }. Only the named secrets are resolved and injected for this run (read via process.env.API_TOKEN / $API_TOKEN); on a satellite they are delivered just-in-time over the encrypted channel, never persisted. Values are masked out of the collector output.',
125
+ ),
91
126
  timeout: requestTimeoutMs().describe("Maximum execution time in milliseconds"),
92
127
  });
93
128
 
@@ -247,27 +282,65 @@ export class InlineScriptCollector implements CollectorStrategy<
247
282
  });
248
283
 
249
284
  private executor: InlineScriptExecutor;
285
+ private getResolutionRoot?: () => Promise<ResolutionRootStatus>;
250
286
 
251
- constructor(executor: InlineScriptExecutor = defaultInlineScriptExecutor) {
287
+ constructor(
288
+ executor: InlineScriptExecutor = defaultInlineScriptExecutor,
289
+ getResolutionRoot?: () => Promise<ResolutionRootStatus>,
290
+ ) {
252
291
  this.executor = executor;
292
+ this.getResolutionRoot = getResolutionRoot;
253
293
  }
254
294
 
255
295
  async execute({
256
296
  config,
297
+ runContext,
298
+ secretEnv,
257
299
  }: {
258
300
  config: InlineScriptConfig;
259
301
  client: ScriptTransportClient;
260
302
  pluginId: string;
303
+ runContext?: CollectorRunContext;
304
+ secretEnv?: Record<string, string>;
261
305
  }): Promise<CollectorResult<InlineScriptResult>> {
262
306
  const startTime = Date.now();
263
307
 
308
+ // Resolve the managed npm-package root. `notReady` -> fail clearly;
309
+ // `none` -> unset (no packages); `ready` -> point the runner at it.
310
+ const rootStatus = await this.getResolutionRoot?.();
311
+ if (rootStatus?.mode === "notReady") {
312
+ return {
313
+ result: {
314
+ success: false,
315
+ message: rootStatus.reason,
316
+ executionTimeMs: Date.now() - startTime,
317
+ timedOut: false,
318
+ },
319
+ error: rootStatus.reason,
320
+ };
321
+ }
322
+ const resolutionRoot =
323
+ rootStatus?.mode === "ready" ? rootStatus.root : undefined;
324
+
325
+ // Source-side masking values: the run's delivered secret values.
326
+ const maskValues = Object.values(secretEnv ?? {});
327
+
264
328
  let exec: InlineScriptExecutionResult;
265
329
  try {
266
- exec = await this.executor.execute({
330
+ const raw = await this.executor.execute({
267
331
  script: config.script,
268
332
  config: config as unknown as Record<string, unknown>,
269
333
  timeoutMs: config.timeout,
334
+ runContext,
335
+ ...(resolutionRoot ? { resolutionRoot } : {}),
336
+ ...(secretEnv ? { secretEnv } : {}),
270
337
  });
338
+ // Redact the delivered secret values from the captured output BEFORE
339
+ // any of it leaves the satellite (defense in depth: core masks again
340
+ // on receipt). A script echoing a secret it was given is masked here.
341
+ // `raw` carries `stdout`/`stderr` (required by ScriptRunOutput) plus
342
+ // `timedOut`, so the masked result keeps the full shape.
343
+ exec = maskScriptRunOutput({ output: raw, values: maskValues });
271
344
  } catch (error) {
272
345
  const executionTimeMs = Date.now() - startTime;
273
346
  const message = extractErrorMessage(error);
package/tsconfig.json CHANGED
@@ -12,6 +12,12 @@
12
12
  },
13
13
  {
14
14
  "path": "../../core/healthcheck-common"
15
+ },
16
+ {
17
+ "path": "../../core/script-packages-backend"
18
+ },
19
+ {
20
+ "path": "../../core/secrets-common"
15
21
  }
16
22
  ]
17
23
  }