@checkstack/backend-api 0.15.3 → 0.16.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 +69 -0
- package/package.json +3 -3
- package/src/base-strategy-config.ts +19 -0
- package/src/esm-script-runner.test.ts +169 -0
- package/src/esm-script-runner.ts +467 -0
- package/src/index.ts +2 -0
- package/src/shell-script-runner.ts +175 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# @checkstack/backend-api
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a06b899: Dead-code audit cleanup and a small platform of shared notification helpers.
|
|
8
|
+
|
|
9
|
+
**Removed (dead code)**
|
|
10
|
+
|
|
11
|
+
- `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`.
|
|
12
|
+
- `createMockQueueFactory` deprecated alias removed from `@checkstack/test-utils-backend`. Use `createMockQueueManager` directly.
|
|
13
|
+
|
|
14
|
+
**New shared helpers**
|
|
15
|
+
|
|
16
|
+
- `@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.
|
|
17
|
+
- `@checkstack/notification-common` now exports `SubjectStatusSchema` / `SubjectStatus`, mirroring the existing `ImportanceSchema`.
|
|
18
|
+
- `@checkstack/notification-backend` now exports:
|
|
19
|
+
- `SUBJECT_STATUS_EMOJI` / `IMPORTANCE_EMOJI` — the shared status / importance emoji maps that Discord, Slack, Teams, Webex and Telegram previously each redefined inline.
|
|
20
|
+
- `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 }`.
|
|
21
|
+
|
|
22
|
+
**Migrated to shared helpers**
|
|
23
|
+
|
|
24
|
+
- Discord, Slack, Gotify, Pushover notification backends now use `postJson`. Outer try/catch + per-plugin error mapping deleted (~140 LOC).
|
|
25
|
+
- Discord, Slack, Teams, Telegram, Webex notification backends now use `IMPORTANCE_EMOJI`. Discord, Slack, Teams use `SUBJECT_STATUS_EMOJI`.
|
|
26
|
+
- 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`).
|
|
27
|
+
|
|
28
|
+
**API surface tightening**
|
|
29
|
+
|
|
30
|
+
- 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.
|
|
31
|
+
|
|
32
|
+
- a06b899: Extract shared `EsmScriptRunner` + `ShellScriptRunner` utilities, fix HIGH-severity privilege amplification in the integration TS provider, and harden the integration shell setupGuide example.
|
|
33
|
+
|
|
34
|
+
**SECURITY FIX (HIGH)**
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
**BREAKING CHANGE (`@checkstack/integration-script-backend`)**
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
**REFACTOR — new shared utilities in `@checkstack/backend-api`**
|
|
45
|
+
|
|
46
|
+
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:
|
|
47
|
+
|
|
48
|
+
- **`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).
|
|
49
|
+
- **`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`.
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
**FIXES**
|
|
54
|
+
|
|
55
|
+
- 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).
|
|
56
|
+
- 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.
|
|
57
|
+
|
|
58
|
+
**TESTS**
|
|
59
|
+
|
|
60
|
+
- 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.
|
|
61
|
+
- The plugin-local `inline-script-normaliser.test.ts` was deleted; the same coverage (plus more) lives at the canonical location with the utility.
|
|
62
|
+
- 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.
|
|
63
|
+
|
|
64
|
+
2047 tests pass, lint + typecheck clean.
|
|
65
|
+
|
|
66
|
+
### Patch Changes
|
|
67
|
+
|
|
68
|
+
- @checkstack/cache-api@0.3.3
|
|
69
|
+
- @checkstack/queue-api@0.3.3
|
|
70
|
+
- @checkstack/healthcheck-common@1.1.1
|
|
71
|
+
|
|
3
72
|
## 0.15.3
|
|
4
73
|
|
|
5
74
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/backend-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@checkstack/common": "0.10.0",
|
|
14
14
|
"@checkstack/healthcheck-common": "1.1.0",
|
|
15
|
-
"@checkstack/cache-api": "0.3.
|
|
16
|
-
"@checkstack/queue-api": "0.3.
|
|
15
|
+
"@checkstack/cache-api": "0.3.2",
|
|
16
|
+
"@checkstack/queue-api": "0.3.2",
|
|
17
17
|
"@checkstack/signal-common": "0.2.3",
|
|
18
18
|
"@orpc/client": "^1.13.14",
|
|
19
19
|
"@orpc/contract": "^1.13.14",
|
|
@@ -24,3 +24,22 @@ export const baseStrategyConfigSchema = z.object({
|
|
|
24
24
|
* Base config type that all strategy configs must satisfy.
|
|
25
25
|
*/
|
|
26
26
|
export type BaseStrategyConfig = z.infer<typeof baseStrategyConfigSchema>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Shared timeout field for outbound HTTP requests (webhooks, integration
|
|
30
|
+
* deliveries, inline scripts). Standardises bounds and default across plugins
|
|
31
|
+
* so operators see a consistent UX in the config UI.
|
|
32
|
+
*
|
|
33
|
+
* Bounds: `1_000ms` to `60_000ms`, default `10_000ms`.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* const provider = z.object({
|
|
38
|
+
* url: configString({}).url(),
|
|
39
|
+
* timeout: requestTimeoutMs().describe("Request timeout in milliseconds"),
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function requestTimeoutMs() {
|
|
44
|
+
return configNumber({}).min(1000).max(60_000).default(10_000);
|
|
45
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
normaliseUserScript,
|
|
4
|
+
rewriteHelperImports,
|
|
5
|
+
} from "./esm-script-runner";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The shared ESM-script runner applies two text transforms to user
|
|
9
|
+
* source before writing it to a temp `.mjs` file:
|
|
10
|
+
*
|
|
11
|
+
* 1. `normaliseUserScript` — wraps legacy `return X;` style scripts in
|
|
12
|
+
* an async IIFE so they're valid as a module's default export,
|
|
13
|
+
* while leaving real ESM modules (those with top-level
|
|
14
|
+
* `import`/`export`) untouched.
|
|
15
|
+
*
|
|
16
|
+
* 2. `rewriteHelperImports` — rewrites `from "<helperModuleName>"`
|
|
17
|
+
* to point at a runtime helper file written next to the user
|
|
18
|
+
* script.
|
|
19
|
+
*
|
|
20
|
+
* Both are easy to regress (a regex tweak that gets greedier than
|
|
21
|
+
* intended, an edge case with comments, …) and both directly affect
|
|
22
|
+
* whether user scripts run at all. Keep this suite tight.
|
|
23
|
+
*
|
|
24
|
+
* These were previously tested under
|
|
25
|
+
* `plugins/healthcheck-script-backend/src/inline-script-normaliser.test.ts`;
|
|
26
|
+
* the utilities now live in `@checkstack/backend-api` and are shared by
|
|
27
|
+
* both healthcheck (inline-script-collector) and integration
|
|
28
|
+
* (script-provider).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
describe("normaliseUserScript", () => {
|
|
32
|
+
it("wraps a bare `return` body in an async IIFE default export", () => {
|
|
33
|
+
const out = normaliseUserScript("return { success: true };");
|
|
34
|
+
expect(out).toContain("export default await (async () => {");
|
|
35
|
+
expect(out).toContain("return { success: true };");
|
|
36
|
+
expect(out).toContain("})();");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("wraps a no-return body (side-effect script) the same way", () => {
|
|
40
|
+
const out = normaliseUserScript("console.log('hi');");
|
|
41
|
+
expect(out).toContain("export default await (async () => {");
|
|
42
|
+
expect(out).toContain("console.log('hi');");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("leaves a real ESM module (with top-level `import`) untouched", () => {
|
|
46
|
+
const src = `import { loadavg } from "node:os";\nexport default { success: true };`;
|
|
47
|
+
expect(normaliseUserScript(src)).toBe(src);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("leaves a module with only `export default` (no import) untouched", () => {
|
|
51
|
+
const src = `export default { success: true };`;
|
|
52
|
+
expect(normaliseUserScript(src)).toBe(src);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("leaves a script with `export` after leading whitespace untouched", () => {
|
|
56
|
+
// Top-level matters regardless of leading whitespace — the regex is
|
|
57
|
+
// anchored with `^\s*` per-line via the `m` flag.
|
|
58
|
+
const src = ` export default 42;`;
|
|
59
|
+
expect(normaliseUserScript(src)).toBe(src);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("treats indented `return`-style scripts as legacy (wraps them)", () => {
|
|
63
|
+
// Per current behaviour: only top-of-line `export`/`import` qualifies
|
|
64
|
+
// as ESM. Anything else gets wrapped. This is intentional — a script
|
|
65
|
+
// that contains the substring "import" inside a comment shouldn't be
|
|
66
|
+
// misclassified as ESM and have its `return` left dangling.
|
|
67
|
+
const src = ` // imports are great\n return true;`;
|
|
68
|
+
expect(normaliseUserScript(src)).toContain("export default await");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("appends a trailing newline before `})` so a trailing `//` comment doesn't swallow the brace", () => {
|
|
72
|
+
const out = normaliseUserScript("return true; // trailing comment");
|
|
73
|
+
// Confirm the wrapper structure didn't get clobbered by the comment.
|
|
74
|
+
expect(out).toMatch(/return true; \/\/ trailing comment\s*\n\}\)\(\)/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("rewriteHelperImports", () => {
|
|
79
|
+
const HELPER_URL = "file:///tmp/checkstack-script-abc/_helpers.mjs";
|
|
80
|
+
|
|
81
|
+
it("rewrites a named import from the helper module", () => {
|
|
82
|
+
const out = rewriteHelperImports({
|
|
83
|
+
userScript: `import { defineHealthCheck } from "@checkstack/healthcheck";`,
|
|
84
|
+
helperModuleName: "@checkstack/healthcheck",
|
|
85
|
+
helperUrl: HELPER_URL,
|
|
86
|
+
});
|
|
87
|
+
expect(out).toBe(`import { defineHealthCheck } from "${HELPER_URL}";`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("works with single-quoted import specs too", () => {
|
|
91
|
+
const out = rewriteHelperImports({
|
|
92
|
+
userScript: `import { defineHealthCheck } from '@checkstack/healthcheck';`,
|
|
93
|
+
helperModuleName: "@checkstack/healthcheck",
|
|
94
|
+
helperUrl: HELPER_URL,
|
|
95
|
+
});
|
|
96
|
+
expect(out).toBe(`import { defineHealthCheck } from "${HELPER_URL}";`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rewrites a side-effect import too", () => {
|
|
100
|
+
const out = rewriteHelperImports({
|
|
101
|
+
userScript: `import "@checkstack/healthcheck";`,
|
|
102
|
+
helperModuleName: "@checkstack/healthcheck",
|
|
103
|
+
helperUrl: HELPER_URL,
|
|
104
|
+
});
|
|
105
|
+
expect(out).toBe(`import "${HELPER_URL}";`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("leaves other imports alone", () => {
|
|
109
|
+
const src = `import { loadavg } from "node:os";\nimport fs from "node:fs/promises";`;
|
|
110
|
+
expect(
|
|
111
|
+
rewriteHelperImports({
|
|
112
|
+
userScript: src,
|
|
113
|
+
helperModuleName: "@checkstack/healthcheck",
|
|
114
|
+
helperUrl: HELPER_URL,
|
|
115
|
+
}),
|
|
116
|
+
).toBe(src);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rewrites multiple occurrences", () => {
|
|
120
|
+
const src = `
|
|
121
|
+
import { defineHealthCheck } from "@checkstack/healthcheck";
|
|
122
|
+
import type { HealthCheckScriptResult } from "@checkstack/healthcheck";
|
|
123
|
+
`;
|
|
124
|
+
const out = rewriteHelperImports({
|
|
125
|
+
userScript: src,
|
|
126
|
+
helperModuleName: "@checkstack/healthcheck",
|
|
127
|
+
helperUrl: HELPER_URL,
|
|
128
|
+
});
|
|
129
|
+
expect(out.match(/@checkstack\/healthcheck/g)).toBeNull();
|
|
130
|
+
expect([...out.matchAll(new RegExp(HELPER_URL, "g"))].length).toBe(2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("doesn't rewrite the package name if it appears in a string literal", () => {
|
|
134
|
+
// Conservative regex: it only matches the spec position of an import
|
|
135
|
+
// statement (`from "..."` / `import "..."`). A string containing the
|
|
136
|
+
// package name elsewhere must be left alone.
|
|
137
|
+
const src = `console.log("Look up @checkstack/healthcheck on npm");`;
|
|
138
|
+
expect(
|
|
139
|
+
rewriteHelperImports({
|
|
140
|
+
userScript: src,
|
|
141
|
+
helperModuleName: "@checkstack/healthcheck",
|
|
142
|
+
helperUrl: HELPER_URL,
|
|
143
|
+
}),
|
|
144
|
+
).toBe(src);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("rewrites the integration helper module too (regex is parameterised)", () => {
|
|
148
|
+
// The shared runner is used by both healthcheck and integration
|
|
149
|
+
// plugins; the regex is built from the supplied `helperModuleName`.
|
|
150
|
+
// Sanity check that the integration variant works.
|
|
151
|
+
const out = rewriteHelperImports({
|
|
152
|
+
userScript: `import { defineIntegration } from "@checkstack/integration";`,
|
|
153
|
+
helperModuleName: "@checkstack/integration",
|
|
154
|
+
helperUrl: HELPER_URL,
|
|
155
|
+
});
|
|
156
|
+
expect(out).toBe(`import { defineIntegration } from "${HELPER_URL}";`);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("escapes regex metacharacters in the helper module name", () => {
|
|
160
|
+
// Defence-in-depth: if someone ever passes a name with special
|
|
161
|
+
// chars, we don't want it interpreted as a regex.
|
|
162
|
+
const out = rewriteHelperImports({
|
|
163
|
+
userScript: `import x from "weird.pkg+name";`,
|
|
164
|
+
helperModuleName: "weird.pkg+name",
|
|
165
|
+
helperUrl: HELPER_URL,
|
|
166
|
+
});
|
|
167
|
+
expect(out).toBe(`import x from "${HELPER_URL}";`);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { spawn, type Subprocess } from "bun";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared sandbox for executing user-authored TypeScript / JavaScript
|
|
10
|
+
* modules in a fresh Bun subprocess.
|
|
11
|
+
*
|
|
12
|
+
* Used by both `@checkstack/healthcheck-script-backend` (the inline
|
|
13
|
+
* health-check collector) and `@checkstack/integration-script-backend`
|
|
14
|
+
* (the script integration provider). The two had near-identical inline
|
|
15
|
+
* implementations; this module is the canonical version.
|
|
16
|
+
*
|
|
17
|
+
* Why subprocess isolation matters:
|
|
18
|
+
*
|
|
19
|
+
* Running user code via `new Function(script)` in the satellite's
|
|
20
|
+
* own process gives the user `globalThis.process`, `node:fs`, etc.
|
|
21
|
+
* — they can read every secret in `process.env` (DB URLs, signing
|
|
22
|
+
* keys, queue creds) and exfiltrate them through the result. Even
|
|
23
|
+
* `manage`-level users typically have no legitimate API for those
|
|
24
|
+
* secrets, so in-process eval is a privilege amplification.
|
|
25
|
+
*
|
|
26
|
+
* By spawning a separate Bun process with a curated `SAFE_ENV_VARS`
|
|
27
|
+
* subset, the user's script gets the full Node/Bun standard library
|
|
28
|
+
* to work with but cannot see the satellite's environment.
|
|
29
|
+
*
|
|
30
|
+
* Concurrency note: each `run()` invocation is fully isolated.
|
|
31
|
+
*
|
|
32
|
+
* - `mkdtemp` guarantees a unique directory name (POSIX-atomic).
|
|
33
|
+
* - The result-marker session id is a `randomUUID`, so each
|
|
34
|
+
* subprocess's stderr is unambiguously its own — even if user
|
|
35
|
+
* scripts happen to write text that looks like another invocation's
|
|
36
|
+
* marker.
|
|
37
|
+
* - The subprocess is launched with `cmd: [bun, runner.mjs]` whose
|
|
38
|
+
* references to the temp dir are absolute paths in env/argv. Two
|
|
39
|
+
* concurrent subprocesses can never read each other's user.mjs.
|
|
40
|
+
*
|
|
41
|
+
* Cleanup is `finally`-guaranteed: the timeout handle is cleared, any
|
|
42
|
+
* straggler subprocess is killed (idempotent on an already-exited
|
|
43
|
+
* process), and the temp dir is removed recursively — on success, on
|
|
44
|
+
* thrown error, AND on timeout.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// PUBLIC TYPES
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
export interface EsmScriptRunResult {
|
|
52
|
+
/** Raw value the user script returned (default export or legacy IIFE return). */
|
|
53
|
+
result?: unknown;
|
|
54
|
+
/** Error message if the script threw or failed to load. */
|
|
55
|
+
error?: string;
|
|
56
|
+
/** Stack trace if available. */
|
|
57
|
+
stack?: string;
|
|
58
|
+
/** Anything the script wrote to stdout — caller can surface as logs. */
|
|
59
|
+
stdout: string;
|
|
60
|
+
/** Anything the script wrote to stderr (with our result-marker stripped). */
|
|
61
|
+
stderr: string;
|
|
62
|
+
/** True if the timeout fired before the subprocess exited. */
|
|
63
|
+
timedOut: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface EsmScriptRunOptions {
|
|
67
|
+
/** User-supplied script source (modern ESM with `import`/`export`, or legacy `return X;`). */
|
|
68
|
+
script: string;
|
|
69
|
+
/** Object to expose as `globalThis.context` inside the subprocess. JSON-serialised; no functions / cycles. */
|
|
70
|
+
context: unknown;
|
|
71
|
+
/** Maximum execution time in milliseconds. */
|
|
72
|
+
timeoutMs: number;
|
|
73
|
+
/**
|
|
74
|
+
* Optional virtual module name that the user's script can `import`
|
|
75
|
+
* from. We write a sibling `_helpers.mjs` in the temp dir that
|
|
76
|
+
* exports a single identity function under `helperFunctionName`, and
|
|
77
|
+
* rewrite any `from "<helperModuleName>"` import in the user source
|
|
78
|
+
* to point at that file. Skipped if either field is omitted.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* helperModuleName: "@checkstack/healthcheck"
|
|
82
|
+
* helperFunctionName: "defineHealthCheck"
|
|
83
|
+
* // editor: import { defineHealthCheck } from "@checkstack/healthcheck"
|
|
84
|
+
* // runtime: import { defineHealthCheck } from "file:///tmp/.../_helpers.mjs"
|
|
85
|
+
*/
|
|
86
|
+
helperModuleName?: string;
|
|
87
|
+
/** Name of the helper function injected as a global AND exported by the virtual module. */
|
|
88
|
+
helperFunctionName?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Injectable interface. Production code calls
|
|
93
|
+
* `defaultEsmScriptRunner.run()`; tests can pass a mock to skip the
|
|
94
|
+
* actual subprocess spawn.
|
|
95
|
+
*/
|
|
96
|
+
export interface EsmScriptRunner {
|
|
97
|
+
run(options: EsmScriptRunOptions): Promise<EsmScriptRunResult>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// INTERNALS
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Vars passed through to the subprocess. We intentionally do NOT
|
|
106
|
+
* forward the satellite's full env so backend secrets (DB URLs, API
|
|
107
|
+
* tokens, signing keys) never reach user-authored scripts. PATH / HOME
|
|
108
|
+
* / LANG / ... are kept so `node:child_process`, `node:fs`, and
|
|
109
|
+
* locale-sensitive APIs behave normally.
|
|
110
|
+
*/
|
|
111
|
+
const SAFE_ENV_VARS = [
|
|
112
|
+
"PATH",
|
|
113
|
+
"HOME",
|
|
114
|
+
"USER",
|
|
115
|
+
"LANG",
|
|
116
|
+
"LC_ALL",
|
|
117
|
+
"LC_CTYPE",
|
|
118
|
+
"TZ",
|
|
119
|
+
"TMPDIR",
|
|
120
|
+
"HOSTNAME",
|
|
121
|
+
"SHELL",
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
function pickSafeEnv(): Record<string, string> {
|
|
125
|
+
const env: Record<string, string> = {};
|
|
126
|
+
for (const key of SAFE_ENV_VARS) {
|
|
127
|
+
const value = process.env[key];
|
|
128
|
+
if (value !== undefined) {
|
|
129
|
+
env[key] = value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return env;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// =============================================================================
|
|
136
|
+
// USER-SCRIPT NORMALISATION
|
|
137
|
+
// =============================================================================
|
|
138
|
+
|
|
139
|
+
const ESM_AT_TOP_LEVEL = /^\s*(import\s|export\s)/m;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Make the user's source loadable as an ES module.
|
|
143
|
+
*
|
|
144
|
+
* Three shapes are supported:
|
|
145
|
+
* 1. **Real module** (contains `import` / `export` at top level) — used as-is.
|
|
146
|
+
* 2. **Legacy IIFE-style** (`return X;` at top level) — wrapped in an
|
|
147
|
+
* async IIFE whose return value becomes the default export.
|
|
148
|
+
* 3. **Side-effect only** — treated as healthy unless it throws.
|
|
149
|
+
*/
|
|
150
|
+
export function normaliseUserScript(userScript: string): string {
|
|
151
|
+
if (ESM_AT_TOP_LEVEL.test(userScript)) {
|
|
152
|
+
return userScript;
|
|
153
|
+
}
|
|
154
|
+
// Trailing newline so a `// comment` on the last line doesn't swallow
|
|
155
|
+
// the closing brace.
|
|
156
|
+
return `export default await (async () => {\n${userScript}\n})();\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Rewrite imports of a virtual module to point at a real on-disk
|
|
161
|
+
* helper file. The user writes a clean package import in the editor
|
|
162
|
+
* (with IntelliSense from the virtual ambient module), and at runtime
|
|
163
|
+
* we redirect it to a local sibling.
|
|
164
|
+
*
|
|
165
|
+
* The regex is anchored to the literal spec position of `from "..."` /
|
|
166
|
+
* `import "..."` — it doesn't touch substrings of comments or string
|
|
167
|
+
* literals.
|
|
168
|
+
*/
|
|
169
|
+
export function rewriteHelperImports({
|
|
170
|
+
userScript,
|
|
171
|
+
helperModuleName,
|
|
172
|
+
helperUrl,
|
|
173
|
+
}: {
|
|
174
|
+
userScript: string;
|
|
175
|
+
helperModuleName: string;
|
|
176
|
+
helperUrl: string;
|
|
177
|
+
}): string {
|
|
178
|
+
const escapedName = helperModuleName.replaceAll(
|
|
179
|
+
/[.*+?^${}()|[\]\\]/g,
|
|
180
|
+
String.raw`\$&`,
|
|
181
|
+
);
|
|
182
|
+
const fromRe = new RegExp(
|
|
183
|
+
String.raw`(from\s+)(["'])${escapedName}\2`,
|
|
184
|
+
"g",
|
|
185
|
+
);
|
|
186
|
+
const sideEffectRe = new RegExp(
|
|
187
|
+
String.raw`(import\s+)(["'])${escapedName}\2`,
|
|
188
|
+
"g",
|
|
189
|
+
);
|
|
190
|
+
return userScript
|
|
191
|
+
.replaceAll(fromRe, (_match, fromKw: string) =>
|
|
192
|
+
`${fromKw}${JSON.stringify(helperUrl)}`,
|
|
193
|
+
)
|
|
194
|
+
.replaceAll(sideEffectRe, (_match, importKw: string) =>
|
|
195
|
+
`${importKw}${JSON.stringify(helperUrl)}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// =============================================================================
|
|
200
|
+
// RUNNER GENERATION
|
|
201
|
+
// =============================================================================
|
|
202
|
+
|
|
203
|
+
function buildHelperSource(helperFunctionName: string): string {
|
|
204
|
+
return `// Auto-generated. Identity helper that exists only so the editor can
|
|
205
|
+
// type-check the user's return shape. The runtime is intentionally trivial.
|
|
206
|
+
export function ${helperFunctionName}(value) { return value; }
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildRunnerSource({
|
|
211
|
+
userScriptUrl,
|
|
212
|
+
contextJson,
|
|
213
|
+
helperFunctionName,
|
|
214
|
+
markerStart,
|
|
215
|
+
markerEnd,
|
|
216
|
+
}: {
|
|
217
|
+
userScriptUrl: string;
|
|
218
|
+
contextJson: string;
|
|
219
|
+
helperFunctionName: string | undefined;
|
|
220
|
+
markerStart: string;
|
|
221
|
+
markerEnd: string;
|
|
222
|
+
}): string {
|
|
223
|
+
const helperGlobal = helperFunctionName
|
|
224
|
+
? `globalThis.${helperFunctionName} = (value) => value;\n`
|
|
225
|
+
: "";
|
|
226
|
+
|
|
227
|
+
// `String.raw` so embedded \n / \\ in the generated source survive
|
|
228
|
+
// verbatim to the temp file — the runner needs to write a real
|
|
229
|
+
// newline at runtime, which means the file on disk needs the two
|
|
230
|
+
// characters `\n` (not a real LF).
|
|
231
|
+
return String.raw`// Auto-generated runner for an inline user-script execution.
|
|
232
|
+
// Sets up the user-facing globals, imports the user module, captures the
|
|
233
|
+
// result, and writes it back to the parent through a stderr marker.
|
|
234
|
+
|
|
235
|
+
globalThis.context = ${contextJson};
|
|
236
|
+
${helperGlobal}
|
|
237
|
+
const __markerStart = ${JSON.stringify(markerStart)};
|
|
238
|
+
const __markerEnd = ${JSON.stringify(markerEnd)};
|
|
239
|
+
|
|
240
|
+
function __emit(payload) {
|
|
241
|
+
// Single-line JSON, sandwiched between unique markers. The parent
|
|
242
|
+
// process does a lastIndexOf() to find it and is tolerant of
|
|
243
|
+
// arbitrary user output on stderr above.
|
|
244
|
+
process.stderr.write(__markerStart + JSON.stringify(payload) + __markerEnd + "\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const __mod = await import(${JSON.stringify(userScriptUrl)});
|
|
249
|
+
let __result;
|
|
250
|
+
if (__mod && "default" in __mod && __mod.default !== undefined) {
|
|
251
|
+
const __def = __mod.default;
|
|
252
|
+
__result =
|
|
253
|
+
typeof __def === "function"
|
|
254
|
+
? await __def(globalThis.context)
|
|
255
|
+
: __def;
|
|
256
|
+
}
|
|
257
|
+
__emit({ ok: true, result: __result ?? null });
|
|
258
|
+
} catch (err) {
|
|
259
|
+
const __message =
|
|
260
|
+
err && typeof err === "object" && "message" in err
|
|
261
|
+
? String(err.message)
|
|
262
|
+
: String(err);
|
|
263
|
+
const __stack =
|
|
264
|
+
err && typeof err === "object" && "stack" in err
|
|
265
|
+
? String(err.stack)
|
|
266
|
+
: undefined;
|
|
267
|
+
__emit({ ok: false, error: __message, stack: __stack });
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// =============================================================================
|
|
274
|
+
// MARKER PAYLOAD VALIDATION
|
|
275
|
+
// =============================================================================
|
|
276
|
+
|
|
277
|
+
type RunnerPayload =
|
|
278
|
+
| { ok: true; result: unknown }
|
|
279
|
+
| { ok: false; error: string; stack?: string };
|
|
280
|
+
|
|
281
|
+
function isRunnerPayload(value: unknown): value is RunnerPayload {
|
|
282
|
+
if (typeof value !== "object" || value === null) return false;
|
|
283
|
+
const v = value as Record<string, unknown>;
|
|
284
|
+
if (v.ok === true) return true;
|
|
285
|
+
if (v.ok === false && typeof v.error === "string") return true;
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// =============================================================================
|
|
290
|
+
// DEFAULT RUNNER
|
|
291
|
+
// =============================================================================
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Default runner implementation. Production code should use this; tests
|
|
295
|
+
* can substitute a mock that conforms to {@link EsmScriptRunner}.
|
|
296
|
+
*/
|
|
297
|
+
export const defaultEsmScriptRunner: EsmScriptRunner = {
|
|
298
|
+
async run({
|
|
299
|
+
script,
|
|
300
|
+
context,
|
|
301
|
+
timeoutMs,
|
|
302
|
+
helperModuleName,
|
|
303
|
+
helperFunctionName,
|
|
304
|
+
}) {
|
|
305
|
+
const sessionId = randomUUID();
|
|
306
|
+
const markerStart = `##__CS_SCRIPT_RESULT_${sessionId}_START__##`;
|
|
307
|
+
const markerEnd = `##__CS_SCRIPT_RESULT_${sessionId}_END__##`;
|
|
308
|
+
|
|
309
|
+
const tmpDir = await mkdtemp(path.join(tmpdir(), "checkstack-script-"));
|
|
310
|
+
const userScriptPath = path.join(tmpDir, "user.mjs");
|
|
311
|
+
const runnerPath = path.join(tmpDir, "runner.mjs");
|
|
312
|
+
|
|
313
|
+
const hasHelper =
|
|
314
|
+
typeof helperModuleName === "string" &&
|
|
315
|
+
helperModuleName.length > 0 &&
|
|
316
|
+
typeof helperFunctionName === "string" &&
|
|
317
|
+
helperFunctionName.length > 0;
|
|
318
|
+
const helperPath = hasHelper ? path.join(tmpDir, "_helpers.mjs") : undefined;
|
|
319
|
+
const helperUrl = helperPath ? pathToFileURL(helperPath).href : undefined;
|
|
320
|
+
|
|
321
|
+
let proc: Subprocess | undefined;
|
|
322
|
+
let timedOut = false;
|
|
323
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
324
|
+
|
|
325
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
326
|
+
timeoutHandle = setTimeout(() => {
|
|
327
|
+
timedOut = true;
|
|
328
|
+
proc?.kill();
|
|
329
|
+
reject(new Error("__TIMEOUT__"));
|
|
330
|
+
}, timeoutMs);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
// Helper module first so the user's
|
|
335
|
+
// `import { <fn> } from "<helperModuleName>"` (which we rewrite
|
|
336
|
+
// to point at this file's URL) resolves at module-evaluation time.
|
|
337
|
+
if (helperPath && helperFunctionName) {
|
|
338
|
+
await writeFile(helperPath, buildHelperSource(helperFunctionName), "utf8");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const normalisedSource = normaliseUserScript(script);
|
|
342
|
+
const userSource =
|
|
343
|
+
hasHelper && helperUrl
|
|
344
|
+
? rewriteHelperImports({
|
|
345
|
+
userScript: normalisedSource,
|
|
346
|
+
helperModuleName: helperModuleName!,
|
|
347
|
+
helperUrl,
|
|
348
|
+
})
|
|
349
|
+
: normalisedSource;
|
|
350
|
+
|
|
351
|
+
await writeFile(userScriptPath, userSource, "utf8");
|
|
352
|
+
await writeFile(
|
|
353
|
+
runnerPath,
|
|
354
|
+
buildRunnerSource({
|
|
355
|
+
userScriptUrl: pathToFileURL(userScriptPath).href,
|
|
356
|
+
contextJson: JSON.stringify(context),
|
|
357
|
+
helperFunctionName: hasHelper ? helperFunctionName : undefined,
|
|
358
|
+
markerStart,
|
|
359
|
+
markerEnd,
|
|
360
|
+
}),
|
|
361
|
+
"utf8",
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
proc = spawn({
|
|
365
|
+
cmd: [process.execPath, runnerPath],
|
|
366
|
+
env: pickSafeEnv(),
|
|
367
|
+
stdout: "pipe",
|
|
368
|
+
stderr: "pipe",
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
let stdout: string;
|
|
372
|
+
let stderr: string;
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
[stdout, stderr] = (await Promise.race([
|
|
376
|
+
Promise.all([
|
|
377
|
+
new Response(proc.stdout as ReadableStream).text(),
|
|
378
|
+
new Response(proc.stderr as ReadableStream).text(),
|
|
379
|
+
proc.exited,
|
|
380
|
+
]),
|
|
381
|
+
timeoutPromise,
|
|
382
|
+
])) as [string, string, number];
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (timedOut) {
|
|
385
|
+
return {
|
|
386
|
+
stdout: "",
|
|
387
|
+
stderr: "",
|
|
388
|
+
timedOut: true,
|
|
389
|
+
error: "Script execution timed out",
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Pluck the runner payload out of stderr.
|
|
396
|
+
const startIdx = stderr.lastIndexOf(markerStart);
|
|
397
|
+
const endIdx = stderr.lastIndexOf(markerEnd);
|
|
398
|
+
|
|
399
|
+
let cleanStderr = stderr;
|
|
400
|
+
let payload: RunnerPayload | undefined;
|
|
401
|
+
|
|
402
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
403
|
+
const jsonStr = stderr.slice(startIdx + markerStart.length, endIdx);
|
|
404
|
+
try {
|
|
405
|
+
const parsed: unknown = JSON.parse(jsonStr);
|
|
406
|
+
if (isRunnerPayload(parsed)) {
|
|
407
|
+
payload = parsed;
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
// Fall through to the "no marker" branch.
|
|
411
|
+
}
|
|
412
|
+
cleanStderr = (
|
|
413
|
+
stderr.slice(0, startIdx) + stderr.slice(endIdx + markerEnd.length)
|
|
414
|
+
)
|
|
415
|
+
.replace(/\n$/, "")
|
|
416
|
+
.trim();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!payload) {
|
|
420
|
+
// The runner never got far enough to emit — typically a syntax
|
|
421
|
+
// error in the user module or a hard crash. Surface whatever the
|
|
422
|
+
// subprocess wrote to stderr as the error.
|
|
423
|
+
return {
|
|
424
|
+
stdout: stdout.trim(),
|
|
425
|
+
stderr: cleanStderr,
|
|
426
|
+
timedOut: false,
|
|
427
|
+
error:
|
|
428
|
+
cleanStderr.length > 0
|
|
429
|
+
? cleanStderr
|
|
430
|
+
: "Script exited without producing a result",
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (payload.ok) {
|
|
435
|
+
return {
|
|
436
|
+
result: payload.result,
|
|
437
|
+
stdout: stdout.trim(),
|
|
438
|
+
stderr: cleanStderr,
|
|
439
|
+
timedOut: false,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
error: payload.error,
|
|
445
|
+
stack: payload.stack,
|
|
446
|
+
stdout: stdout.trim(),
|
|
447
|
+
stderr: cleanStderr,
|
|
448
|
+
timedOut: false,
|
|
449
|
+
};
|
|
450
|
+
} finally {
|
|
451
|
+
// Order matters:
|
|
452
|
+
// 1. Clear the timer (otherwise a fast script leaks an
|
|
453
|
+
// event-loop handle for up to `timeoutMs`).
|
|
454
|
+
// 2. Kill any straggler subprocess. `.kill()` is idempotent on
|
|
455
|
+
// an already-exited process.
|
|
456
|
+
// 3. Remove the tempdir last — after the subprocess can no
|
|
457
|
+
// longer be touching its files.
|
|
458
|
+
if (timeoutHandle !== undefined) {
|
|
459
|
+
clearTimeout(timeoutHandle);
|
|
460
|
+
}
|
|
461
|
+
proc?.kill();
|
|
462
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
463
|
+
// Best-effort. Anything left in /tmp will be reaped by the OS.
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { spawn, type Subprocess } from "bun";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared sandbox for executing user-authored shell scripts through
|
|
5
|
+
* `sh -c`.
|
|
6
|
+
*
|
|
7
|
+
* Used by both `@checkstack/healthcheck-script-backend` (the shell
|
|
8
|
+
* health-check strategy) and `@checkstack/integration-script-backend`
|
|
9
|
+
* (the shell integration provider). The two had near-identical inline
|
|
10
|
+
* implementations; this module is the canonical version.
|
|
11
|
+
*
|
|
12
|
+
* Why a curated env: a script author already has authority to execute
|
|
13
|
+
* arbitrary shell, but we must not leak the satellite's own secrets to
|
|
14
|
+
* them. The forwarded env is the minimum needed for ordinary commands
|
|
15
|
+
* (`awk`, `curl`, `git`, locale-aware tools) to behave correctly:
|
|
16
|
+
* `PATH` so binaries resolve, `HOME` / `USER` so tools find their
|
|
17
|
+
* config, `LANG` / `LC_*` so output is parseable, `TZ` so timestamps
|
|
18
|
+
* are consistent, `TMPDIR` so `mktemp` works. Everything else
|
|
19
|
+
* (DB URLs, signing keys, queue creds, etc.) is dropped.
|
|
20
|
+
*
|
|
21
|
+
* Cleanup is `finally`-guaranteed: the timeout handle is cleared so a
|
|
22
|
+
* fast script doesn't leak an event-loop timer, and any straggler
|
|
23
|
+
* subprocess is `.kill()`-ed (idempotent on an already-exited
|
|
24
|
+
* process). This matches the pattern in the ESM script runner.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// PUBLIC TYPES
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
export interface ShellScriptRunResult {
|
|
32
|
+
/** Exit code reported by the subprocess. -1 if it never started or timed out. */
|
|
33
|
+
exitCode: number;
|
|
34
|
+
/** Captured stdout, trimmed of trailing newlines. */
|
|
35
|
+
stdout: string;
|
|
36
|
+
/** Captured stderr, trimmed of trailing newlines. */
|
|
37
|
+
stderr: string;
|
|
38
|
+
/** True if the timeout fired before the subprocess exited. */
|
|
39
|
+
timedOut: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ShellScriptRunOptions {
|
|
43
|
+
/** Shell-script source. Fed verbatim to `sh -c`, so pipes, redirects, etc. work. */
|
|
44
|
+
script: string;
|
|
45
|
+
/** Maximum execution time in milliseconds. */
|
|
46
|
+
timeoutMs: number;
|
|
47
|
+
/** Optional working directory for the subprocess. */
|
|
48
|
+
cwd?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Optional extra environment variables. Merged on top of the
|
|
51
|
+
* safe-vars whitelist (`PATH`, `HOME`, ...). User-supplied values
|
|
52
|
+
* win on key collision.
|
|
53
|
+
*
|
|
54
|
+
* Note: callers should validate the keys themselves if they need to
|
|
55
|
+
* forbid `LD_PRELOAD`, `NODE_OPTIONS`, etc. — at the shared-runner
|
|
56
|
+
* layer we accept whatever the caller passes, because the legitimate
|
|
57
|
+
* use cases (e.g. integration shell scripts injecting `PAYLOAD_*`
|
|
58
|
+
* vars) vary too much.
|
|
59
|
+
*/
|
|
60
|
+
env?: Record<string, string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Injectable interface. Production code calls
|
|
65
|
+
* `defaultShellScriptRunner.run()`; tests can pass a mock to skip the
|
|
66
|
+
* actual subprocess spawn.
|
|
67
|
+
*/
|
|
68
|
+
export interface ShellScriptRunner {
|
|
69
|
+
run(options: ShellScriptRunOptions): Promise<ShellScriptRunResult>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// =============================================================================
|
|
73
|
+
// INTERNALS
|
|
74
|
+
// =============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Vars passed through to the subprocess. We intentionally do NOT
|
|
78
|
+
* forward the satellite's full env so backend secrets (DB URLs, API
|
|
79
|
+
* tokens, signing keys) never reach user-authored scripts.
|
|
80
|
+
*/
|
|
81
|
+
const SAFE_ENV_VARS = [
|
|
82
|
+
"PATH",
|
|
83
|
+
"HOME",
|
|
84
|
+
"USER",
|
|
85
|
+
"LANG",
|
|
86
|
+
"LC_ALL",
|
|
87
|
+
"LC_CTYPE",
|
|
88
|
+
"TZ",
|
|
89
|
+
"TMPDIR",
|
|
90
|
+
"HOSTNAME",
|
|
91
|
+
"SHELL",
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function pickSafeEnv(): Record<string, string> {
|
|
95
|
+
const env: Record<string, string> = {};
|
|
96
|
+
for (const key of SAFE_ENV_VARS) {
|
|
97
|
+
const value = process.env[key];
|
|
98
|
+
if (value !== undefined) {
|
|
99
|
+
env[key] = value;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return env;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// DEFAULT RUNNER
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default runner implementation. Production code should use this; tests
|
|
111
|
+
* can substitute a mock that conforms to {@link ShellScriptRunner}.
|
|
112
|
+
*/
|
|
113
|
+
export const defaultShellScriptRunner: ShellScriptRunner = {
|
|
114
|
+
async run({ script, timeoutMs, cwd, env }) {
|
|
115
|
+
let proc: Subprocess | undefined;
|
|
116
|
+
let timedOut = false;
|
|
117
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
118
|
+
|
|
119
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
120
|
+
timeoutHandle = setTimeout(() => {
|
|
121
|
+
timedOut = true;
|
|
122
|
+
proc?.kill();
|
|
123
|
+
reject(new Error("Script execution timed out"));
|
|
124
|
+
}, timeoutMs);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
// Execute through `sh -c` so the user's script can use pipes,
|
|
129
|
+
// redirects, variable expansion, conditionals, command
|
|
130
|
+
// substitution, etc. — i.e. behave like a real shell script
|
|
131
|
+
// rather than a single argv vector.
|
|
132
|
+
proc = spawn({
|
|
133
|
+
cmd: ["sh", "-c", script],
|
|
134
|
+
cwd,
|
|
135
|
+
env: { ...pickSafeEnv(), ...env },
|
|
136
|
+
stdout: "pipe",
|
|
137
|
+
stderr: "pipe",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const [stdout, stderr, exitCode] = await Promise.race([
|
|
141
|
+
Promise.all([
|
|
142
|
+
new Response(proc.stdout as ReadableStream).text(),
|
|
143
|
+
new Response(proc.stderr as ReadableStream).text(),
|
|
144
|
+
proc.exited,
|
|
145
|
+
]),
|
|
146
|
+
timeoutPromise,
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
exitCode,
|
|
151
|
+
stdout: stdout.trim(),
|
|
152
|
+
stderr: stderr.trim(),
|
|
153
|
+
timedOut: false,
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (timedOut) {
|
|
157
|
+
return {
|
|
158
|
+
exitCode: -1,
|
|
159
|
+
stdout: "",
|
|
160
|
+
stderr: "Script execution timed out",
|
|
161
|
+
timedOut: true,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
} finally {
|
|
166
|
+
if (timeoutHandle !== undefined) {
|
|
167
|
+
clearTimeout(timeoutHandle);
|
|
168
|
+
}
|
|
169
|
+
// Idempotent — no-op when the subprocess has already exited
|
|
170
|
+
// cleanly, but guarantees we never leave a runaway `sh` from
|
|
171
|
+
// an exception path.
|
|
172
|
+
proc?.kill();
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|