Files
Hubert Gancarczyk 1a7e97fb14 feat(flows): run a local script in a bounded child process (#864)
Adds the executor a flow `script:` step runs on: a fresh Node child per
step,
a protocol over its IPC channel, and the two watchdogs that make a hung
or
runaway script the runner's problem rather than the host's.

The step itself is not here. This branch stops at the executor and its
unit
tests; `feat/flow-script-step` stacks the YAML directive, the runner
integration, and the reference docs on top.

## What it does

- **One child per step.** Spawned with an old-space heap limit, an
explicit
working directory, and an environment built from an allowlist rather
than
  copied from the tool server.
- **A deadline and a lifeline.** A separate watchdog holds the deadline,
so a
script that wedges the event loop still dies on time; a second one reaps
the
whole process tree when the parent goes away, so a grandchild cannot
outlive
  the run.
- **A concurrency queue.** Slots are bounded per server. A step that
never gets
one is refused with a message that says so, and an aborted run frees its
slot
  immediately.
- **Log budgets.** 64 KiB per step and 256 KiB per run, counted on the
bytes
the report keeps: redaction and V8 frame collapsing both run before
anything
is counted, so what the limits bound is the size of the report rather
than
  the size of the script's writes.
- **Secret hold-back.** The scrub walks a chunk and stops where a value
could
still begin, so neither half of a value split across two chunks is
released
  on its own, and a shorter value is never taken where the longer one
  containing it has not arrived yet.
- **A failure taxonomy.** Twelve kinds, split into what the script did
(it
threw, it did not load, it exited non-zero, it wrote an unusable
`output`)
and what the host did to it (a limit, a signal, a spawn that failed, a
queue
  slot it never got).

## Docs

`packages/docs/docs/reference/configuration.mdx` lists the two
configuration
keys this branch adds, `scripts.maxTimeoutMs` and `scripts.heapLimitMb`.
The
`script:` step itself is documented with the step, on
`feat/flow-script-step`.

## Verification

`npm run build`, `npx eslint . --max-warnings 0`, `npx prettier --check
.`,
`npm run knip`, `npm run typecheck:scripts`, the test typecheck across
every
workspace, the tool-server suite (4719 passed, 1 skipped) and `npm run
test:scripts` (92 tests) are green, as is `npx docusaurus build`. The
executor's
own behaviour is covered by the eight `test/flows/script/` files added
here,
against real child processes, and the compiled `dist/` executor was
driven
against its copied runner assets over every path the review reached.

Each review fix carries the run that reproduced it before the change and
the
mutation that proves the new test fails without it: the prefix-secret
leak and
all three npm `node-options` routes were reproduced end to end through
the
executor against real child processes, and the five coverage findings
were
confirmed by re-applying the exact mutation each thread named.




<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added reliable execution of trusted flow scripts with concurrency
control, cancellation, timeouts, memory limits, logging, and detailed
failure reporting.
* Added safeguards for process cleanup, watchdog termination, output
validation, and secret redaction.
* Added global configuration for maximum script runtime and memory
usage.
* **Bug Fixes**
* Improved handling of script failures, stalled processes, malformed
output, and child-process termination.
* **Documentation**
  * Documented script resource limits and global configuration behavior.
* **Chores**
* Updated builds and packaging to include required flow-script runtime
assets.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-09-07 11:34:30 +02:00

190 lines
11 KiB
JSON

{
// Anti-dead-code gate: unused files, exports and types nothing imports, and
// dependencies declared but never used (plus the inverse). Run it with
// `npm run knip`; .github/workflows/knip.yml enforces it.
//
// Every issue type knip reports is an error by default, so this file sets no
// `rules`. There is no parked backlog either: `--max-issues 0` means both
// passes must come back empty, and one new unused export fails the job.
//
// Empty is not the same as exhaustive. Two shapes get past both passes, and
// it is the invocation below that hides them, not knip. A dead member of a
// referenced `enum` needs `enumMembers` and `nsTypes` in the SAME run
// (`knip/dist/graph/analyze.js:72`); pass 1 has the first without the second,
// pass 2 the reverse. A dead specifier on a same-workspace `export … from`
// line is unreportable in pass 2, because naming `classMembers` turns
// `isSkipLibs` off and that enables the `hasExternalReferences` guard, for
// which the declaration in the re-exported module is itself a reference; pass
// 1 excludes `exports` and `types` outright. Both measured on a dist-free
// tree: a dead `ServiceState` member, and a dead `Cookie` type plus a dead
// `createTabsManager` value re-export, each leave `npm run knip` at exit 0,
// while a plain dead `export const` in the same tree fails it. So treat a
// green run as "the shape almost everyone writes is clean", not as proof the
// symbol you just orphaned still has a reader.
//
// `npm run knip` still runs knip twice because the two flags partition the
// issue types rather than layering. `--include` is restrictive, so the pass
// that names `classMembers`, `nsExports` and `nsTypes` — which it must, since
// they sit in knip's own `defaultExcludedIssueTypes` and are otherwise gated
// by neither pass — reports nothing else, and the first pass covers the rest.
// Naming `classMembers` also turns knip's `isSkipLibs` off, which moves the
// export and type counts, so a run that names it is not comparable to one
// that does not.
//
// Run it the way CI does, against a tree with no build output: whether a
// workspace resolves `main`/`types` to a file that exists changes what knip
// reaches, so a run after a local `tsc --build` is no evidence the job will
// pass.
"$schema": "https://unpkg.com/knip@5/schema.json",
// Makes a stale exemption here fail the job instead of printing a hint below
// the backlog — an `ignoreDependencies` name that suppresses nothing, an
// `entry` that matches nothing or is already reached, an `ignoreWorkspaces`
// path that does not exist. Mind that last one: if the submodule directory
// below goes missing the hint fires advising you to drop the entry, which is
// the one thing not to do. Restore the directory instead.
"treatConfigHintsAsErrors": true,
// Ships from its own repo with its own CI, and eslint and prettier skip it the
// same way. It becomes a workspace the moment `git submodule update --init`
// puts a package.json where the root `packages/*` glob matches it.
"ignoreWorkspaces": ["packages/argent-private"],
"workspaces": {
// Knip finds the root scripts on its own — via package.json `scripts`, the
// scripts/*.test.mjs files those run, and `run:` steps in .github/workflows.
// What its github-actions plugin cannot parse is a PowerShell body, so the
// Windows E2E driver, invoked from the windows-latest job where that is the
// default shell, is the one entry declared by hand.
//
// Knip analyzes JS and TS only, so the shell half of scripts/ — the whole
// scripts/e2e-full/ harness included — is outside the gate however it runs.
".": {
"entry": ["scripts/ci/windows-chromium-e2e.mjs"],
},
// @swmansion/argent is a thin dispatcher whose real payload is an esbuild
// bundle of the OTHER workspaces (see this workspace's
// scripts/bundle-tools.cjs); the hand-written dispatcher copied into bin/ at
// build time is an entry, not dead code.
//
// Knip cannot follow imports across that esbuild boundary, so a package
// consumed only by the bundled-in code reads as unused here. They are named
// one by one rather than waved through with a catch-all pattern:
// `ignoreDependencies` also suppresses `unlisted`, so a catch-all would take
// this workspace's own src/ and scripts/ out of that check — where an import
// with no matching declaration is a real bug, not a bundler artifact.
"packages/argent": {
"entry": ["scripts/argent-simulator-server.cjs"],
"ignoreDependencies": [
// dependencies
"@fails-components/webtransport",
// Not bundled-in code reaching it: @fails-components/webtransport
// imports this itself at runtime, so nothing first-party imports it.
"@fails-components/webtransport-transport-http3-quiche",
"@modelcontextprotocol/sdk",
"tree-sitter",
"tree-sitter-typescript",
// devDependencies
"@clack/prompts",
"@opentelemetry/api-logs",
"@opentelemetry/exporter-logs-otlp-http",
"@opentelemetry/resources",
"@opentelemetry/sdk-logs",
"picocolors",
"semver",
"smol-toml",
"yaml",
],
},
// Three members in `packages/registry` are reported unused even though
// tool-server calls all three. The cause is the unbuilt tree, the same one
// the note at the top of this file describes: every workspace resolves
// `main`/`types` to a `dist/` that is not there, so `@argent/registry`
// resolves to nothing and the edge from tool-server never forms. Build the
// tree first and all three findings go away on their own — which is why the
// exemption is needed at all, since CI never builds.
//
// Nothing about this is specific to class members. Every cross-workspace
// reference is invisible the same way; the other issue types simply have no
// finding to raise here. They are exempted at the declaration with a
// `@public` JSDoc tag rather than by name here, for one reason: scope.
// `ignoreMembers` matches a NAME across the whole workspace, so it would
// also hide a future dead `register`/`list`/`off` on any other class in
// `packages/registry`. `@public` binds to the one member it is written on.
//
// What it does NOT buy is a stale check. `treatConfigHintsAsErrors` cannot
// report either form: knip emits no hint for an `ignoreMembers` name that
// suppresses nothing, and it emits a tag hint only for tags in the exclude
// half of the `tags` option, which this repo does not set. Both were
// measured — a bogus `ignoreMembers` name and a redundant `@public` each
// leave the run at exit 0, while a bogus `ignoreDependencies` name fails it.
// So when one of these three stops being earned, nothing says so, and the
// JSDoc at each member is the only record a re-audit has.
//
// tool-server reaches a few packages in ways knip can't trace statically.
// `ignoreDependencies` works in both directions, so this one list silences
// two different issue classes; they are kept apart below so each entry's
// stated reason matches the finding it actually suppresses. Each entry
// exempts the NAME across the whole workspace, not the call site it
// documents — while `sharp` is listed, an undeclared import of it from any
// file here passes too — so the list holds only packages this workspace
// genuinely cannot declare.
"packages/tool-server": {
// Reached only dynamically, so knip cannot see them: the executor puts
// the runner in front of the script as an `--import` preload by path, and
// the runner starts each watchdog with `new Worker(url)`.
"entry": [
"src/tools/flows/script/flow-script-runner.mjs",
"src/tools/flows/script/flow-script-watchdog-lifeline.mjs",
"src/tools/flows/script/flow-script-watchdog-deadline.mjs",
],
"ignoreDependencies": [
// --- Declared in package.json, loaded too dynamically to detect.
// Without these: "unused dependencies". ---
// Dynamic `_require("tree-sitter")` / `_require("tree-sitter-typescript")`
// in react-profiler/pipeline/.../ast-index.ts, behind a one-shot flag.
"tree-sitter",
"tree-sitter-typescript",
// Optional peers of @swmansion/argent-cloud-sdk, which leaves them to
// the host to declare. utils/moq-client.ts calls the SDK's
// `installNodeWebTransport()`, but the dynamic import of the polyfill
// sits inside the SDK's own /node entry, so no first-party file names
// it; the transport is a second hop, imported by the polyfill itself.
"@fails-components/webtransport",
"@fails-components/webtransport-transport-http3-quiche",
// --- Imported here but deliberately absent from this package.json.
// Without these: "unlisted dependencies". ---
// Lazy `require("sharp")` in chromium-server/screenshot.ts. Kept out of
// the manifest on purpose so the ~30 MB native binary isn't a cost every
// consumer pays; the caller degrades gracefully when it isn't installed.
"sharp",
// `require("electron")` twice, for two different reasons. In
// utils/preview-window.ts it resolves the Electron executable's path
// from the install root, where it is an optionalDependency of the
// published @swmansion/argent. In test/fixtures/electron-smoke-app it
// is the Electron main-process API; that fixture declares electron in
// its own package.json and CI installs it there, but knip attributes
// the file to this workspace's manifest and never reads the nested one.
"electron",
],
},
},
// Blind spot in the `files` check: test files are entry points, so a
// production module that nothing but its own test imports still counts as
// reached — which in this repo is most of them. What `files` reliably catches
// is a module with no test at all. A file orphaned inside test/ is invisible
// for the same reason, knip's node plugin registering those trees as entries.
// `knip --production` is the lens that reports the whole class; the repo is
// not clean under it yet, so expect findings there that this gate does not
// raise.
//
// Reachability has a wider hole still: whatever a workspace's entry re-exports
// is reached, so one `export { x } from "./dead-module";` in a src/index.ts
// takes that module out of `files` altogether. A clean run means nothing knip
// can see is dead — not that nothing is.
}