@stackfactor/agent-utils 1.2.16 → 1.2.19

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/dist/esm/serve.js CHANGED
@@ -14,11 +14,14 @@ const dynamicImport = new Function("specifier", "return import(specifier)");
14
14
  // own fetch/SDK calls can cooperate, and we fail the check if it overruns.
15
15
  const SELF_CHECK_TIMEOUT_MS = 20_000;
16
16
  /**
17
- * Resolves a repo-relative check-module path within the app root, rejecting any
17
+ * Resolves a repo-relative agent-module path within the app root, rejecting any
18
18
  * path that escapes it or is absolute (the path may originate from the
19
19
  * integration config, so treat it as untrusted). Returns null when unsafe.
20
+ *
21
+ * Guards both entry points that name a module by configuration: HealthCheck's
22
+ * `check_code` and Execute's `code` (a webhook's handler module).
20
23
  */
21
- const resolveCheckPath = (rel) => {
24
+ const resolveAgentModulePath = (rel) => {
22
25
  const root = process.cwd();
23
26
  const dest = resolve(root, rel);
24
27
  const back = relative(root, dest);
@@ -27,6 +30,60 @@ const resolveCheckPath = (rel) => {
27
30
  }
28
31
  return dest;
29
32
  };
33
+ /**
34
+ * Loads the function exported by a repo-relative agent module. Shared by the
35
+ * self-check (`check_code`) and the webhook entry point (`code`) so both get the
36
+ * same untrusted-path guard and the same CJS/ESM interop, and neither can drift
37
+ * into resolving a path the other would reject.
38
+ *
39
+ * Never throws: every failure comes back as a `reason` the caller maps to its
40
+ * own error shape (a CheckResult row, or a gRPC error frame).
41
+ *
42
+ * @param rel - Repo-relative module path, e.g. "src/webhooks/inbound-sms.js".
43
+ * @param namedExport - Preferred named export, tried after `default`/bare export.
44
+ */
45
+ const loadAgentModule = async (rel, namedExport) => {
46
+ const modulePath = resolveAgentModulePath(rel);
47
+ if (!modulePath) {
48
+ return {
49
+ reason: "invalid-path",
50
+ detail: `invalid module path "${rel}"`,
51
+ };
52
+ }
53
+ if (!existsSync(modulePath)) {
54
+ return {
55
+ reason: "not-found",
56
+ detail: `module "${rel}" was not found in the agent image`,
57
+ };
58
+ }
59
+ let exported;
60
+ try {
61
+ // Real dynamic import via an indirection the TS compiler won't rewrite. A
62
+ // literal `import()` is downlevelled to `require()` in the CJS build, and
63
+ // `require()` cannot resolve a file:// URL ("Cannot find module
64
+ // 'file:///app/src/check.js'"). `new Function` keeps a genuine `import()` in
65
+ // both builds; Node resolves the file URL and loads CJS or ESM modules
66
+ // alike (CJS exports surface as the namespace `default`).
67
+ const mod = await dynamicImport(pathToFileURL(modulePath).href);
68
+ exported = mod?.default ?? mod;
69
+ }
70
+ catch (error) {
71
+ return {
72
+ reason: "load-failed",
73
+ detail: String(error?.message ?? error),
74
+ };
75
+ }
76
+ const fn = typeof exported === "function"
77
+ ? exported
78
+ : exported?.[namedExport] ?? exported?.default;
79
+ if (typeof fn !== "function") {
80
+ return {
81
+ reason: "not-a-function",
82
+ detail: `module "${rel}" does not export a function`,
83
+ };
84
+ }
85
+ return { fn: fn };
86
+ };
30
87
  /** Coerces whatever the agent's check returned into well-formed CheckResult[]. */
31
88
  const normalizeChecks = (raw) => {
32
89
  if (!Array.isArray(raw)) {
@@ -67,60 +124,41 @@ const runSelfCheck = async (checkCode, config, session) => {
67
124
  },
68
125
  ];
69
126
  }
70
- const modulePath = resolveCheckPath(rel);
71
- if (!modulePath) {
72
- return [
73
- {
127
+ const loaded = await loadAgentModule(rel, "check");
128
+ if (loaded.reason) {
129
+ // These rows are the ones this check reported before the loader was factored
130
+ // out, preserved exactly: a missing or unreachable module is a non-blocking
131
+ // `warn` (synthetic testing simply isn't available), while a module that IS
132
+ // there but broken is a blocking `error` — a real defect in a live agent.
133
+ const rows = {
134
+ "invalid-path": {
74
135
  name: "synthetic_check",
75
136
  ok: false,
76
137
  detail: `invalid checkCode path "${rel}"`,
77
138
  severity: "warn",
78
139
  },
79
- ];
80
- }
81
- if (!existsSync(modulePath)) {
82
- return [
83
- {
140
+ "not-found": {
84
141
  name: "synthetic_check",
85
142
  ok: false,
86
143
  detail: `checkCode "${rel}" is configured but the module was not found in the agent image`,
87
144
  severity: "warn",
88
145
  },
89
- ];
90
- }
91
- let checkFn;
92
- try {
93
- // Real dynamic import via an indirection the TS compiler won't rewrite. A
94
- // literal `import()` is downlevelled to `require()` in the CJS build, and
95
- // `require()` cannot resolve a file:// URL ("Cannot find module
96
- // 'file:///app/src/check.js'"). `new Function` keeps a genuine `import()` in
97
- // both builds; Node resolves the file URL and loads CJS or ESM check
98
- // modules alike (CJS exports surface as the namespace `default`).
99
- const mod = await dynamicImport(pathToFileURL(modulePath).href);
100
- const exported = mod?.default ?? mod;
101
- checkFn =
102
- typeof exported === "function" ? exported : exported?.check ?? exported?.default;
103
- }
104
- catch (error) {
105
- return [
106
- {
146
+ "load-failed": {
107
147
  name: "self_check",
108
148
  ok: false,
109
- detail: `failed to load check module: ${error?.message ?? error}`,
149
+ detail: `failed to load check module: ${loaded.detail}`,
110
150
  severity: "error",
111
151
  },
112
- ];
113
- }
114
- if (typeof checkFn !== "function") {
115
- return [
116
- {
152
+ "not-a-function": {
117
153
  name: "self_check",
118
154
  ok: false,
119
155
  detail: "check module does not export a function",
120
156
  severity: "error",
121
157
  },
122
- ];
158
+ };
159
+ return [rows[loaded.reason]];
123
160
  }
161
+ const checkFn = loaded.fn;
124
162
  const abort = new AbortController();
125
163
  const timer = setTimeout(() => abort.abort(), SELF_CHECK_TIMEOUT_MS);
126
164
  // Bind the same per-request globals main() sees (config, request.session, …)
@@ -321,8 +359,37 @@ export const serve = (main, options = {}) => {
321
359
  signal: abort.signal,
322
360
  session,
323
361
  };
362
+ // Which function actually runs. Default is the agent's own `main` — the
363
+ // path every caller took before `code` existed, and still the path taken
364
+ // whenever `code` is empty. A webhook instead names its own handler module
365
+ // (integration webHooks[].code), so one agent can serve many endpoints
366
+ // without `main` growing a routing switch. Resolved per request rather
367
+ // than at boot because the value is per-invocation configuration.
368
+ let entry = main;
369
+ const entryPath = String(req.code ?? "").trim();
370
+ if (entryPath) {
371
+ const loaded = await loadAgentModule(entryPath, "handler");
372
+ if (loaded.reason) {
373
+ // A named module that will not load is a deployment/config fault, not
374
+ // a bad request: the caller cannot fix it and retrying will not help.
375
+ // Report it as a result-bearing error frame (not a gRPC status) so the
376
+ // receiver gets the reason verbatim and can log which module failed.
377
+ logger.log(null, logger.levels.error, `agent Execute could not load entry module "${entryPath}" (${loaded.reason}): ${loaded.detail}`);
378
+ if (!abort.signal.aborted) {
379
+ call.write({
380
+ error: {
381
+ code: 500,
382
+ message: `entry module "${entryPath}" could not be loaded: ${loaded.detail}`,
383
+ },
384
+ });
385
+ }
386
+ call.end();
387
+ return;
388
+ }
389
+ entry = loaded.fn;
390
+ }
324
391
  try {
325
- const result = await runWithContext(context, () => main(abort.signal));
392
+ const result = await runWithContext(context, () => entry(abort.signal));
326
393
  if (!abort.signal.aborted) {
327
394
  call.write({
328
395
  result: { result_json: JSON.stringify(result ?? null) },
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.16",
6
+ "version": "1.2.19",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",
@@ -19,7 +19,7 @@
19
19
  "work:release": "bash scripts/release.sh",
20
20
  "release": "bash scripts/release.sh",
21
21
  "release:fanout": "bash scripts/release-fanout.sh",
22
- "ar-auth": "npm config set @stackfactor:registry https://us-central1-npm.pkg.dev/virtual-development-team/sf-devenv-npm-private/; TOKEN=$(gcloud auth print-access-token 2>/dev/null || curl -sfH 'Metadata-Flavor: Google' http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token | sed -n 's/.*\"access_token\":\"\\([^\"]*\\)\".*/\\1/p'); test -n \"$TOKEN\" || { echo 'ar-auth: no credential — run: gcloud auth login' >&2; exit 1; }; npm config set //us-central1-npm.pkg.dev/virtual-development-team/:_authToken \"$TOKEN\"; echo 'ar-auth: Artifact Registry scope + token configured'",
22
+ "ar-auth": "npm config set @stackfactor:registry https://us-central1-npm.pkg.dev/virtual-development-team/sf-devenv-npm-private/; TOKEN=$(gcloud auth print-access-token 2>/dev/null || curl -sfH 'Metadata-Flavor: Google' http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token | sed -n 's/.*\"access_token\":\"\\([^\"]*\\)\".*/\\1/p'); test -n \"$TOKEN\" || { echo 'ar-auth: no credential — run: gcloud auth login' >&2; exit 1; }; npm config set //us-central1-npm.pkg.dev/virtual-development-team/sf-devenv-npm-private/:_authToken \"$TOKEN\"; npm config set //us-central1-npm.pkg.dev/virtual-development-team/:_authToken \"$TOKEN\"; echo 'ar-auth: Artifact Registry scope + token configured'",
23
23
  "reinstall": "npm run ar-auth && npm ci"
24
24
  },
25
25
  "repository": {