@checkstack/backend-api 0.19.0 → 0.21.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/package.json +12 -11
  3. package/src/advisory-lock-pool.it.test.ts +282 -0
  4. package/src/advisory-lock.test.ts +144 -3
  5. package/src/advisory-lock.ts +97 -55
  6. package/src/auth-strategy.ts +6 -3
  7. package/src/bearer-token.ts +13 -0
  8. package/src/collector-strategy.ts +9 -0
  9. package/src/config-versioning.test.ts +227 -0
  10. package/src/config-versioning.ts +172 -0
  11. package/src/core-services.ts +14 -0
  12. package/src/esm-script-runner.test.ts +55 -16
  13. package/src/esm-script-runner.ts +212 -55
  14. package/src/index.ts +3 -0
  15. package/src/render-templatable-config.test.ts +168 -0
  16. package/src/render-templatable-config.ts +193 -0
  17. package/src/schema-utils.ts +3 -0
  18. package/src/script-sandbox/capabilities.test.ts +122 -0
  19. package/src/script-sandbox/capabilities.ts +372 -0
  20. package/src/script-sandbox/capped-output.test.ts +116 -0
  21. package/src/script-sandbox/capped-output.ts +172 -0
  22. package/src/script-sandbox/env-guard.test.ts +105 -0
  23. package/src/script-sandbox/env-guard.ts +129 -0
  24. package/src/script-sandbox/filesystem.test.ts +437 -0
  25. package/src/script-sandbox/filesystem.ts +514 -0
  26. package/src/script-sandbox/forkbomb.it.test.ts +121 -0
  27. package/src/script-sandbox/global-default.test.ts +161 -0
  28. package/src/script-sandbox/global-default.ts +100 -0
  29. package/src/script-sandbox/index.ts +14 -0
  30. package/src/script-sandbox/network.test.ts +356 -0
  31. package/src/script-sandbox/network.ts +373 -0
  32. package/src/script-sandbox/observability.test.ts +210 -0
  33. package/src/script-sandbox/observability.ts +168 -0
  34. package/src/script-sandbox/output-truncation.test.ts +53 -0
  35. package/src/script-sandbox/output-truncation.ts +69 -0
  36. package/src/script-sandbox/policy.test.ts +189 -0
  37. package/src/script-sandbox/policy.ts +220 -0
  38. package/src/script-sandbox/provider.test.ts +61 -0
  39. package/src/script-sandbox/provider.ts +134 -0
  40. package/src/script-sandbox/readiness.test.ts +80 -0
  41. package/src/script-sandbox/readiness.ts +117 -0
  42. package/src/script-sandbox/report.ts +88 -0
  43. package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
  44. package/src/script-sandbox/rootless-egress.test.ts +99 -0
  45. package/src/script-sandbox/rootless-egress.ts +218 -0
  46. package/src/script-sandbox/shell-quote.test.ts +32 -0
  47. package/src/script-sandbox/shell-quote.ts +10 -0
  48. package/src/script-sandbox/wrapper.test.ts +1194 -0
  49. package/src/script-sandbox/wrapper.ts +714 -0
  50. package/src/shell-script-runner.test.ts +243 -0
  51. package/src/shell-script-runner.ts +210 -45
  52. package/src/zod-config.test.ts +60 -0
  53. package/src/zod-config.ts +38 -14
  54. package/tsconfig.json +3 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Tests for the `Versioned<T>` config helper, focused on the
3
+ * assume-v1-on-read parse paths used by configs that are stored
4
+ * UNVERSIONED (raw, nested in a larger JSON blob).
5
+ */
6
+ import { describe, expect, it } from "bun:test";
7
+ import { z } from "zod";
8
+ import {
9
+ Versioned,
10
+ assertMigrationChainFromV1,
11
+ type Migration,
12
+ } from "./config-versioning";
13
+
14
+ // A v1 config: a single object schema, no migrations.
15
+ const v1Schema = z.object({
16
+ url: z.string(),
17
+ method: z.enum(["GET", "POST"]).default("GET"),
18
+ });
19
+
20
+ const v1Config = new Versioned({ version: 1, schema: v1Schema });
21
+
22
+ // A v1 -> v2 config that DROPS a removed `sandbox` key, mirroring the
23
+ // real run_script/run_shell migration.
24
+ const v2Schema = z.object({
25
+ script: z.string(),
26
+ });
27
+
28
+ const dropSandboxMigration: Migration<Record<string, unknown>, unknown> = {
29
+ fromVersion: 1,
30
+ toVersion: 2,
31
+ description: "Drop removed `sandbox` key",
32
+ migrate: ({ sandbox: _sandbox, ...rest }) => rest,
33
+ };
34
+
35
+ const v2Config = new Versioned({
36
+ version: 2,
37
+ schema: v2Schema,
38
+ migrations: [dropSandboxMigration],
39
+ });
40
+
41
+ describe("parseAssumingV1", () => {
42
+ it("validates a v1-no-migration config (just validate)", async () => {
43
+ const result = await v1Config.parseAssumingV1({ url: "https://x.test" });
44
+ expect(result).toEqual({ url: "https://x.test", method: "GET" });
45
+ });
46
+
47
+ it("runs the migration chain for a v>1 config and drops a removed key", async () => {
48
+ const result = await v2Config.parseAssumingV1({
49
+ script: "echo hi",
50
+ sandbox: "off",
51
+ });
52
+ expect(result).toEqual({ script: "echo hi" });
53
+ });
54
+
55
+ it("is idempotent: a fresh config without the removed key is unchanged", async () => {
56
+ const result = await v2Config.parseAssumingV1({ script: "echo hi" });
57
+ expect(result).toEqual({ script: "echo hi" });
58
+ });
59
+
60
+ it("strips unknown keys leniently (does NOT reject typos)", async () => {
61
+ const result = await v1Config.parseAssumingV1({
62
+ url: "https://x.test",
63
+ methodd: "GET",
64
+ });
65
+ expect(result).toEqual({ url: "https://x.test", method: "GET" });
66
+ expect(result).not.toHaveProperty("methodd");
67
+ });
68
+
69
+ it("rejects a genuine validation error (missing required field)", async () => {
70
+ await expect(
71
+ v1Config.parseAssumingV1({ method: "GET" }),
72
+ ).rejects.toThrow();
73
+ });
74
+ });
75
+
76
+ describe("parseStrictAssumingV1", () => {
77
+ it("validates a clean v1-no-migration config", async () => {
78
+ const result = await v1Config.parseStrictAssumingV1({
79
+ url: "https://x.test",
80
+ });
81
+ expect(result).toEqual({ url: "https://x.test", method: "GET" });
82
+ });
83
+
84
+ it("migrates a removed key away, then strict-validates cleanly", async () => {
85
+ const result = await v2Config.parseStrictAssumingV1({
86
+ script: "echo hi",
87
+ sandbox: "off",
88
+ });
89
+ expect(result).toEqual({ script: "echo hi" });
90
+ });
91
+
92
+ it("is idempotent for a fresh config without the removed key", async () => {
93
+ const result = await v2Config.parseStrictAssumingV1({ script: "echo hi" });
94
+ expect(result).toEqual({ script: "echo hi" });
95
+ });
96
+
97
+ it("rejects a genuine unknown-key typo the migration does NOT account for", async () => {
98
+ await expect(
99
+ v1Config.parseStrictAssumingV1({
100
+ url: "https://x.test",
101
+ methodd: "GET",
102
+ }),
103
+ ).rejects.toThrow();
104
+ });
105
+
106
+ it("rejects an unknown key on the migrated (v2) shape", async () => {
107
+ await expect(
108
+ v2Config.parseStrictAssumingV1({ script: "echo hi", typo: 1 }),
109
+ ).rejects.toThrow();
110
+ });
111
+
112
+ it("falls back to a normal parse for non-object schemas", async () => {
113
+ const unionConfig = new Versioned({
114
+ version: 1,
115
+ schema: z.union([z.object({ a: z.string() }), z.object({ b: z.number() })]),
116
+ });
117
+ const result = await unionConfig.parseStrictAssumingV1({ a: "x" });
118
+ expect(result).toEqual({ a: "x" });
119
+ });
120
+ });
121
+
122
+ describe("construction-time migration-chain guard", () => {
123
+ it("throws for a version>1 config with no migrations", () => {
124
+ expect(
125
+ () => new Versioned({ version: 2, schema: v2Schema, migrations: [] }),
126
+ ).toThrow();
127
+ });
128
+
129
+ it("constructs fine for a version 2 config with a complete v1->v2 chain", () => {
130
+ expect(
131
+ () =>
132
+ new Versioned({
133
+ version: 2,
134
+ schema: v2Schema,
135
+ migrations: [dropSandboxMigration],
136
+ }),
137
+ ).not.toThrow();
138
+ });
139
+
140
+ it("constructs fine for a version 1 config with no migrations", () => {
141
+ expect(() => new Versioned({ version: 1, schema: v1Schema })).not.toThrow();
142
+ });
143
+
144
+ it("throws for a broken chain (gap / non-+1 step)", () => {
145
+ expect(
146
+ () =>
147
+ new Versioned({
148
+ version: 4,
149
+ schema: v2Schema,
150
+ migrations: [
151
+ { fromVersion: 1, toVersion: 2, description: "a", migrate: (d) => d },
152
+ { fromVersion: 3, toVersion: 4, description: "c", migrate: (d) => d },
153
+ ],
154
+ }),
155
+ ).toThrow();
156
+ });
157
+ });
158
+
159
+ describe("validateMigrationChainFromV1", () => {
160
+ it("passes for a v1 config with no migrations", () => {
161
+ expect(v1Config.validateMigrationChainFromV1()).toBeUndefined();
162
+ });
163
+
164
+ it("passes for a v2 config with a complete 1->2 chain", () => {
165
+ expect(v2Config.validateMigrationChainFromV1()).toBeUndefined();
166
+ });
167
+
168
+ // NOTE: the "missing covering migration" and "gapped chain" cases now
169
+ // throw at CONSTRUCTION (see the construction-time guard above), so they
170
+ // can no longer be exercised via a constructed instance. The pure
171
+ // structural method itself is covered transitively by the guard tests
172
+ // and by the passing cases below.
173
+ });
174
+
175
+ describe("assertMigrationChainFromV1", () => {
176
+ const step = (fromVersion: number, toVersion: number): Migration => ({
177
+ fromVersion,
178
+ toVersion,
179
+ description: `${fromVersion}->${toVersion}`,
180
+ migrate: (d) => d,
181
+ });
182
+
183
+ it("passes for a v1 config with no migrations", () => {
184
+ expect(() =>
185
+ assertMigrationChainFromV1({ version: 1, migrations: [] }),
186
+ ).not.toThrow();
187
+ });
188
+
189
+ it("passes for a complete contiguous chain (1->2->3)", () => {
190
+ expect(() =>
191
+ assertMigrationChainFromV1({
192
+ version: 3,
193
+ migrations: [step(2, 3), step(1, 2)],
194
+ }),
195
+ ).not.toThrow();
196
+ });
197
+
198
+ it("throws for a version>1 config with no migrations", () => {
199
+ expect(() =>
200
+ assertMigrationChainFromV1({ version: 2, migrations: [] }),
201
+ ).toThrow(/incomplete/);
202
+ });
203
+
204
+ it("throws for a chain that does not reach the target version", () => {
205
+ expect(() =>
206
+ assertMigrationChainFromV1({ version: 3, migrations: [step(1, 2)] }),
207
+ ).toThrow(/incomplete: reaches version 2/);
208
+ });
209
+
210
+ it("throws for a gap in the chain", () => {
211
+ expect(() =>
212
+ assertMigrationChainFromV1({
213
+ version: 4,
214
+ migrations: [step(1, 2), step(3, 4)],
215
+ }),
216
+ ).toThrow(/chain broken: expected migration from version 2/);
217
+ });
218
+
219
+ it("throws for a non-+1 step", () => {
220
+ expect(() =>
221
+ assertMigrationChainFromV1({
222
+ version: 3,
223
+ migrations: [step(1, 3)],
224
+ }),
225
+ ).toThrow(/increment version by 1/);
226
+ });
227
+ });
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { extractErrorMessage } from "@checkstack/common";
2
3
 
3
4
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4
5
  // Storage Interfaces (simple data shapes for DB/API)
@@ -47,6 +48,73 @@ export interface Migration<TFrom = unknown, TTo = unknown> {
47
48
  migrate(data: TFrom): TTo | Promise<TTo>;
48
49
  }
49
50
 
51
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
52
+ // Migration Chain Validation
53
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
54
+
55
+ /**
56
+ * Options for {@link assertMigrationChainFromV1}.
57
+ */
58
+ export interface AssertMigrationChainOptions {
59
+ /** Target/current schema version the chain must reach. */
60
+ version: number;
61
+ /** The ordered (or unordered) set of migrations covering the chain. */
62
+ migrations: Migration<unknown, unknown>[];
63
+ }
64
+
65
+ /**
66
+ * Standalone, pure structural guard that a migration chain is COMPLETE and
67
+ * contiguous from version 1 up to the given `version`: every applicable step
68
+ * increments by exactly 1, there are no gaps, and the chain reaches
69
+ * `version`. No `migrate()` is ever invoked — this only inspects the
70
+ * `fromVersion` / `toVersion` discriminators.
71
+ *
72
+ * A `version: 1` config with no migrations passes trivially. Any
73
+ * `version > 1` requires a covering chain; an incomplete chain is a latent
74
+ * read failure (the read path would only discover it when it first tries to
75
+ * migrate a genuinely-old stored blob), so callers use this to fail fast at
76
+ * boot / registration instead.
77
+ *
78
+ * This is the single shared implementation behind both {@link Versioned}'s
79
+ * construction guard and its non-throwing {@link Versioned.validateMigrationChainFromV1}
80
+ * variant, and is reused by the auth strategy registration guard.
81
+ *
82
+ * @throws Error with a descriptive message on the first problem found.
83
+ */
84
+ export function assertMigrationChainFromV1({
85
+ version,
86
+ migrations,
87
+ }: AssertMigrationChainOptions): void {
88
+ const sorted = migrations.toSorted((a, b) => a.fromVersion - b.fromVersion);
89
+
90
+ let expectedVersion = 1;
91
+ for (const migration of sorted) {
92
+ if (migration.fromVersion < 1) continue;
93
+ if (migration.toVersion > version) break;
94
+
95
+ if (migration.fromVersion !== expectedVersion) {
96
+ throw new Error(
97
+ `Migration chain broken: expected migration from version ${expectedVersion}, ` +
98
+ `but found migration from version ${migration.fromVersion}`
99
+ );
100
+ }
101
+ if (migration.toVersion !== migration.fromVersion + 1) {
102
+ throw new Error(
103
+ `Migration must increment version by 1: migration from ${migration.fromVersion} ` +
104
+ `to ${migration.toVersion} is invalid`
105
+ );
106
+ }
107
+ expectedVersion = migration.toVersion;
108
+ }
109
+
110
+ if (expectedVersion !== version) {
111
+ throw new Error(
112
+ `Migration chain incomplete: reaches version ${expectedVersion}, ` +
113
+ `but target version is ${version}`
114
+ );
115
+ }
116
+ }
117
+
50
118
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
51
119
  // Migration Builder
52
120
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -129,6 +197,18 @@ export class Versioned<T> {
129
197
  this.version = options.version;
130
198
  this.schema = options.schema;
131
199
  this._migrations = options.migrations ?? [];
200
+
201
+ // Fail fast at construction (i.e. at module import / plugin
202
+ // registration) if this Versioned's v1->version migration chain is
203
+ // incomplete or broken. A `version: 1` config with no migrations
204
+ // passes trivially. Any `version > 1` without a covering chain would
205
+ // otherwise fail lazily on the first stale `parseAssumingV1` read — we
206
+ // surface it at boot instead. This single guard covers every Versioned
207
+ // instance repo-wide.
208
+ assertMigrationChainFromV1({
209
+ version: this.version,
210
+ migrations: this._migrations,
211
+ });
132
212
  }
133
213
 
134
214
  /**
@@ -177,6 +257,54 @@ export class Versioned<T> {
177
257
  return { ...migrated, data: validated };
178
258
  }
179
259
 
260
+ /**
261
+ * Parse raw data that was stored UNVERSIONED, assuming it was written
262
+ * at version 1.
263
+ *
264
+ * Many config blobs predate explicit versioning and live nested in a
265
+ * larger JSON document (e.g. an automation `definition`) without a
266
+ * `version` discriminator. Reading them is "assume v1 on read": wrap the
267
+ * raw value as `{ version: 1, data }`, run the migration chain up to the
268
+ * current version, then validate.
269
+ *
270
+ * For a v1-with-no-migrations config this is just a validate; for a
271
+ * config whose `version > 1` it runs the full migration chain first, so
272
+ * removed/renamed fields are migrated away before validation.
273
+ *
274
+ * Validation is LENIENT (unknown keys are stripped, not rejected) — use
275
+ * this on the runtime/read path so a stored config that picked up an
276
+ * extra key survives schema evolution.
277
+ *
278
+ * @throws ZodError on validation failure
279
+ * @throws Error on migration failure
280
+ */
281
+ async parseAssumingV1(rawData: unknown): Promise<T> {
282
+ return this.parse({ version: 1, data: rawData });
283
+ }
284
+
285
+ /**
286
+ * Like {@link parseAssumingV1}, but the FINAL validation is STRICT:
287
+ * unknown keys on a plain-object schema are rejected rather than
288
+ * stripped.
289
+ *
290
+ * Migrate-then-STRICT is the editor/validation path: removed or renamed
291
+ * fields are migrated away by the chain (so stored configs survive
292
+ * schema evolution), while genuine typos — keys no migration accounts
293
+ * for — still surface to the operator.
294
+ *
295
+ * Strict handling mirrors the object-vs-non-object split used by the
296
+ * automation definition validator: only `z.ZodObject` schemas can be
297
+ * tightened with `.strict()`; unions, records, and primitives fall back
298
+ * to a normal parse.
299
+ *
300
+ * @throws ZodError on validation failure
301
+ * @throws Error on migration failure
302
+ */
303
+ async parseStrictAssumingV1(rawData: unknown): Promise<T> {
304
+ const migrated = await this.migrateToVersion({ version: 1, data: rawData });
305
+ return this.strictValidate(migrated.data);
306
+ }
307
+
180
308
  // ─────────────────────────────────────────────────────────────────────────
181
309
  // Data Creation (wrap new data)
182
310
  // ─────────────────────────────────────────────────────────────────────────
@@ -214,6 +342,34 @@ export class Versioned<T> {
214
342
  return input.version !== this.version;
215
343
  }
216
344
 
345
+ /**
346
+ * Structural check (no `migrate()` invocation) that this config has a
347
+ * COMPLETE, contiguous migration chain from version 1 to its current
348
+ * `version`: every step increments by exactly 1 and the chain reaches
349
+ * `version` with no gaps or detours.
350
+ *
351
+ * For a `version: 1` config this is trivially satisfied (no migrations
352
+ * needed). For any `version > 1` it requires a covering chain — which is
353
+ * exactly what {@link parseAssumingV1} relies on, so a missing chain is a
354
+ * latent read failure. Returns the first problem found, or `undefined`
355
+ * when the chain is complete.
356
+ *
357
+ * Intended for a CI contract test that enumerates every registered config
358
+ * and fails the build if a future `version` bump ships without a covering
359
+ * migration — zero per-config upkeep.
360
+ */
361
+ validateMigrationChainFromV1(): string | undefined {
362
+ try {
363
+ assertMigrationChainFromV1({
364
+ version: this.version,
365
+ migrations: this._migrations,
366
+ });
367
+ return undefined;
368
+ } catch (error) {
369
+ return extractErrorMessage(error);
370
+ }
371
+ }
372
+
217
373
  /**
218
374
  * Validate data without migration (schema.parse wrapper).
219
375
  * For data already at current version.
@@ -229,6 +385,22 @@ export class Versioned<T> {
229
385
  return this.schema.safeParse(data);
230
386
  }
231
387
 
388
+ /**
389
+ * Validate in STRICT mode: when the schema is a plain object, unknown
390
+ * keys are rejected; otherwise (unions/records/primitives) fall back to
391
+ * a normal parse. Used by {@link parseStrictAssumingV1}.
392
+ */
393
+ private strictValidate(data: unknown): T {
394
+ if (this.schema instanceof z.ZodObject) {
395
+ // `.strict()` only narrows accepted keys; the parsed output shape is
396
+ // identical to the base object schema's, but its inferred type loses
397
+ // the `T` brand. The cast restores it without changing runtime
398
+ // behaviour.
399
+ return this.schema.strict().parse(data) as T;
400
+ }
401
+ return this.schema.parse(data);
402
+ }
403
+
232
404
  // ─────────────────────────────────────────────────────────────────────────
233
405
  // Internal migration logic
234
406
  // ─────────────────────────────────────────────────────────────────────────
@@ -52,6 +52,20 @@ export const coreServices = {
52
52
  ),
53
53
  rpc: createServiceRef<RpcService>("core.rpc"),
54
54
  rpcClient: createServiceRef<RpcClient>("core.rpcClient"),
55
+ /**
56
+ * Factory for an RPC client that authenticates as a specific APPLICATION
57
+ * (service account), not as the trusted service. The returned client mints a
58
+ * short-lived, backend-signed app-principal token; the live router resolves
59
+ * it to an `application` principal subject to the FULL access-rule and
60
+ * team-scope enforcement (never the service short-circuit). Used by the
61
+ * automation dispatch engine to run an automation as its configured `runAs`.
62
+ *
63
+ * Trusted infra: callable only by backend plugin code. The authority to bind
64
+ * a given application is gated separately when the automation is saved.
65
+ */
66
+ rpcClientAs: createServiceRef<(applicationId: string) => Promise<RpcClient>>(
67
+ "core.rpcClientAs",
68
+ ),
55
69
  queuePluginRegistry: createServiceRef<QueuePluginRegistry>(
56
70
  "core.queuePluginRegistry",
57
71
  ),
@@ -1,4 +1,12 @@
1
- import { afterAll, beforeAll, describe, expect, it } from "bun:test";
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ it,
9
+ } from "bun:test";
2
10
  import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
3
11
  import { tmpdir } from "node:os";
4
12
  import path from "node:path";
@@ -7,6 +15,35 @@ import {
7
15
  normaliseUserScript,
8
16
  rewriteHelperImports,
9
17
  } from "./esm-script-runner";
18
+ import {
19
+ registerSandboxPolicyProvider,
20
+ resetSandboxPolicyProvider,
21
+ } from "./script-sandbox/provider";
22
+ import { resolveDefaultSandboxProfile } from "./script-sandbox/policy";
23
+
24
+ /**
25
+ * The spawning suites register the shipped default profile as the active
26
+ * global policy so the runner's behavior is deterministic (rather than failing
27
+ * closed). The shipped default is now fail-closed (`onUnavailable: "fail"`),
28
+ * which on a capability-poor CI/dev host (non-root macOS, no bwrap/prlimit)
29
+ * would refuse EVERY spawn — defeating these runner-behavior tests (env
30
+ * injection, script execution). So here we register the default profile with
31
+ * the opt-in `degrade` mode to exercise the spawn path on any host. The
32
+ * fail-closed default value is asserted in `policy.test.ts`, and the
33
+ * fail-closed RUNTIME path (refuse + exitCode -1, no spawn) in
34
+ * `provider.test.ts` and `shell-script-runner.test.ts`.
35
+ */
36
+ function useDefaultSandboxPolicy(): void {
37
+ beforeEach(() => {
38
+ registerSandboxPolicyProvider(async () => ({
39
+ ...resolveDefaultSandboxProfile(),
40
+ onUnavailable: "degrade",
41
+ }));
42
+ });
43
+ afterEach(() => {
44
+ resetSandboxPolicyProvider();
45
+ });
46
+ }
10
47
 
11
48
  /**
12
49
  * The shared ESM-script runner applies two text transforms to user
@@ -84,8 +121,8 @@ describe("rewriteHelperImports", () => {
84
121
 
85
122
  it("rewrites a named import from the helper module", () => {
86
123
  const out = rewriteHelperImports({
87
- userScript: `import { defineHealthCheck } from "@checkstack/healthcheck";`,
88
- helperModuleName: "@checkstack/healthcheck",
124
+ userScript: `import { defineHealthCheck } from "@checkstack/sdk/healthcheck";`,
125
+ helperModuleName: "@checkstack/sdk/healthcheck",
89
126
  helperUrl: HELPER_URL,
90
127
  });
91
128
  expect(out).toBe(`import { defineHealthCheck } from "${HELPER_URL}";`);
@@ -93,8 +130,8 @@ describe("rewriteHelperImports", () => {
93
130
 
94
131
  it("works with single-quoted import specs too", () => {
95
132
  const out = rewriteHelperImports({
96
- userScript: `import { defineHealthCheck } from '@checkstack/healthcheck';`,
97
- helperModuleName: "@checkstack/healthcheck",
133
+ userScript: `import { defineHealthCheck } from '@checkstack/sdk/healthcheck';`,
134
+ helperModuleName: "@checkstack/sdk/healthcheck",
98
135
  helperUrl: HELPER_URL,
99
136
  });
100
137
  expect(out).toBe(`import { defineHealthCheck } from "${HELPER_URL}";`);
@@ -102,8 +139,8 @@ describe("rewriteHelperImports", () => {
102
139
 
103
140
  it("rewrites a side-effect import too", () => {
104
141
  const out = rewriteHelperImports({
105
- userScript: `import "@checkstack/healthcheck";`,
106
- helperModuleName: "@checkstack/healthcheck",
142
+ userScript: `import "@checkstack/sdk/healthcheck";`,
143
+ helperModuleName: "@checkstack/sdk/healthcheck",
107
144
  helperUrl: HELPER_URL,
108
145
  });
109
146
  expect(out).toBe(`import "${HELPER_URL}";`);
@@ -114,7 +151,7 @@ describe("rewriteHelperImports", () => {
114
151
  expect(
115
152
  rewriteHelperImports({
116
153
  userScript: src,
117
- helperModuleName: "@checkstack/healthcheck",
154
+ helperModuleName: "@checkstack/sdk/healthcheck",
118
155
  helperUrl: HELPER_URL,
119
156
  }),
120
157
  ).toBe(src);
@@ -122,15 +159,15 @@ describe("rewriteHelperImports", () => {
122
159
 
123
160
  it("rewrites multiple occurrences", () => {
124
161
  const src = `
125
- import { defineHealthCheck } from "@checkstack/healthcheck";
126
- import type { HealthCheckScriptResult } from "@checkstack/healthcheck";
162
+ import { defineHealthCheck } from "@checkstack/sdk/healthcheck";
163
+ import type { HealthCheckScriptResult } from "@checkstack/sdk/healthcheck";
127
164
  `;
128
165
  const out = rewriteHelperImports({
129
166
  userScript: src,
130
- helperModuleName: "@checkstack/healthcheck",
167
+ helperModuleName: "@checkstack/sdk/healthcheck",
131
168
  helperUrl: HELPER_URL,
132
169
  });
133
- expect(out.match(/@checkstack\/healthcheck/g)).toBeNull();
170
+ expect(out.match(/@checkstack\/sdk\/healthcheck/g)).toBeNull();
134
171
  expect([...out.matchAll(new RegExp(HELPER_URL, "g"))].length).toBe(2);
135
172
  });
136
173
 
@@ -138,11 +175,11 @@ describe("rewriteHelperImports", () => {
138
175
  // Conservative regex: it only matches the spec position of an import
139
176
  // statement (`from "..."` / `import "..."`). A string containing the
140
177
  // package name elsewhere must be left alone.
141
- const src = `console.log("Look up @checkstack/healthcheck on npm");`;
178
+ const src = `console.log("Look up @checkstack/sdk/healthcheck on npm");`;
142
179
  expect(
143
180
  rewriteHelperImports({
144
181
  userScript: src,
145
- helperModuleName: "@checkstack/healthcheck",
182
+ helperModuleName: "@checkstack/sdk/healthcheck",
146
183
  helperUrl: HELPER_URL,
147
184
  }),
148
185
  ).toBe(src);
@@ -153,8 +190,8 @@ describe("rewriteHelperImports", () => {
153
190
  // plugins; the regex is built from the supplied `helperModuleName`.
154
191
  // Sanity check that the integration variant works.
155
192
  const out = rewriteHelperImports({
156
- userScript: `import { defineIntegration } from "@checkstack/integration";`,
157
- helperModuleName: "@checkstack/integration",
193
+ userScript: `import { defineIntegration } from "@checkstack/sdk/integration";`,
194
+ helperModuleName: "@checkstack/sdk/integration",
158
195
  helperUrl: HELPER_URL,
159
196
  });
160
197
  expect(out).toBe(`import { defineIntegration } from "${HELPER_URL}";`);
@@ -173,6 +210,7 @@ describe("rewriteHelperImports", () => {
173
210
  });
174
211
 
175
212
  describe("defaultEsmScriptRunner resolutionRoot", () => {
213
+ useDefaultSandboxPolicy();
176
214
  let root: string;
177
215
 
178
216
  beforeAll(async () => {
@@ -232,6 +270,7 @@ describe("defaultEsmScriptRunner resolutionRoot", () => {
232
270
  });
233
271
 
234
272
  describe("defaultEsmScriptRunner injected env", () => {
273
+ useDefaultSandboxPolicy();
235
274
  it("exposes injected env vars as process.env in the subprocess", async () => {
236
275
  const res = await defaultEsmScriptRunner.run({
237
276
  script: `export default process.env.API_TOKEN ?? null;`,