Skip to content

ci: run the package as an embedder ships it, on musl and on macOS 26 - #592

Open
ronleizrowice-ant wants to merge 21 commits into
mainfrom
ci/more-legs
Open

ronleizrowice-ant wants to merge 21 commits into
mainfrom
ci/more-legs

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Human: @ronleizrowice-ant

Requested by Ron · project thread

CI now also runs the built package the way an embedder ships it (bundled, minified, as bytecode), on musl, and on macOS 26. It covered only the unbundled sources, on glibc Linux and two pinned macOS images.

On top of #589 it is +365 −22 in 5 files.

What changes

  • New suite test/smoke/bundled.test.ts builds test/smoke/embedder.ts, a program that imports dist/, with bun build --compile --minify --bytecode, as one file and as split ESM chunks, and runs each from an empty directory. The program wraps a real command, checks what it did on the host and, on Linux, that a refusal keeps its class, name and code.
  • On Linux each build also runs as a multicall executable (seccomp.argv0, ripgrep.argv0), a path no suite ran.
  • It is part of npm test, so every Linux and macOS leg runs it, on Linux with both pairs of bubblewrap and ripgrep.
  • New alpine:3.22 container step: the two uid 0 runs and the new suite, on musl. It uses the workspace as the Ubuntu step left it, with the seccomp helper that step built, static against glibc, because that is the file the npm package brings to a musl host. bun is the musl build, from the release archive by sha256.
  • The Ubuntu container takes the runner's bun, bound in like node, in place of an installer piped into a shell.
  • New macos-26 arm64 leg, told apart by a label.
  • npm run typecheck builds src instead of only checking it, and lint follows it, because test/smoke imports dist/.

Who sees a difference

Known limits

  • Bundling is stock Bun 1.4.2's; an embedder's own bundler settings are not reproduced.
  • A bundle records where its modules were when it was built. On the machine that built it, a lookup that starts from the library's own location therefore still reaches the checkout, so "nothing of the package is around it" is not fully true in CI.
  • The Alpine run covers test/docker-weak-sandbox.test.ts and the new suite only. It is a second step of the Ubuntu job, whose helper, node_modules and dist it uses, so a failure there skips it.
  • The multicall run starts the real helper as a child, not one linked into a runtime.
  • alpine:3.22 and ubuntu:24.04 are tags, and the packages installed inside them float.

Merge order

Stacked on #589: merge that first, squashed. This branch then conflicts with main in two places where the two PRs change neighbouring lines (package.json, the step after npm ci). main gets merged in here at that point, with the tree unchanged, so an approval is best given after that.

main merged in on 2026-10-20

Through #589's branch.

main merged in on 2026-11-06

main at 27fce0d is merged in at 234678f, without a conflict. The new certificate steps run first in both jobs, so the Alpine step comes after them like the Ubuntu one, and the macOS arm64 leg is on macos-15 beside the new macos-26 one.

4f3a8dd and 503075e ride along, with three small things the second review had left as optional:

  • Told where the seccomp helper is, the smoke program now checks that the wrapped command names that file. On the machine that built the bundle the library still finds the checkout's helper, so a bundle that lost the setting passed. It fails now.
  • The "Build project" step goes: the type check before it already builds dist/, and is now called "Build and typecheck".
  • The header of test/docker-weak-sandbox.test.ts names the Alpine run.

Review

  • One automated review pass over the whole diff against main, at 54716d5 (2026-10-20): seventeen findings for what this PR adds to ci: pin ripgrep and the type packages, enforce the lockfile, and run the Node tests on 22 and 24 #589. No fault on the normal path, several checks that could not fail. Answered in bc03da8: a missing helper or ripgrep fails the run, the dependency check reads the warning a missing helper gives, "the network is closed" has a listener on the host as its control, a failed check cleans up, Alpine runs the helper that ships.
  • One automated simplification pass at bc03da8, applied in adbae33. The runner, its script, its CI step, a tsconfig, an eslint block and an install script go. This PR's own part was +493 −10 in 7 files before.
  • No human has reviewed it yet.

Testing

  • Linux x86-64 with bubblewrap 0.12.0 and ripgrep 15.2.0: the new suite's four cases pass in 3 s. They fail with the seccomp helper moved aside, with a helper path that does not exist, and without bubblewrap, and a failed run leaves nothing in the temp directory. The whole suite, at bc03da8, fails the same 24 names as main on the same machine, all for want of an outside network.
  • Not run before the push: the Alpine step, the bound-in bun, macOS. CI runs those: see the checks on the head commit.

main merged in again on 2026-11-09

main at 951e641 is merged in at dd943e9, without a conflict. What this pull request changes is the same as before.

Claude session #01FydxqU


Generated by Claude Code

… hand

@types/node goes from ^18 to ^22 and @types/bun from ^1.3.2 to ^1.4.2,
which is what CI runs. Two things surfaced. The CONNECT handler's `head`
is reassigned with bytes read off the socket, so it is annotated as a
plain Buffer. And the Bun type package redeclares `connect` for
'node:tls' in a way that, against @types/node 22, takes and returns an
untyped value, so the four call sites pin their options with
`satisfies ConnectionOptions`, and a guard in parent-proxy.test.ts fails
the typecheck if ConnectionOptions ever stops being a real type.

Adds `npm run test:node`, which runs the two checks that start the
library under node rather than Bun. The emitted JavaScript changes only
by comment lines.
Linux legs install ripgrep 14.1.1 from the upstream release by sha256
instead of taking whatever apt serves, and run the suites that exercise
the scan a second time against 15.2.0: the two differ in exit status
for an unreadable directory at --max-depth. The main legs move to
Node 22 and run `npm run test:node` on Node 20, 22 and 24; publish
moves from Node 18, below the package's floor, to 22. `npm install`
becomes `npm ci`. Actions are pinned by commit with the version in a
trailing comment.
Node 20 is past its end of life, and nothing here is built or tested on
it first: the legs build on 22. engines becomes >=22, the steps that
ran the node-side checks on Node 20 go, and the package verification in
release.yml covers 22 and 24. An install on Node 20 with engine-strict
set is now refused, where it used to go through.
The suites run the sources unbundled. An application that embeds the
library ships it in one file, minified and compiled to bytecode, where
a function's name, its source text and the files beside it on disk are
all gone, and nothing here would notice code that leans on one of them.

`npm run test:bundled` compiles a small program that imports dist/ that
way and runs it: it initialises the manager, wraps real commands, and
checks on the host what they wrote, that the network was closed, that
the command's own exit status came back and that the mount point for a
denied path is gone afterwards. It imports the built package, so it has
a tsconfig of its own that is checked after the build rather than with
the rest of test/.
Three things CI did not see. The package as an embedder ships it, which
`npm run test:bundled` now runs on every leg that can wrap. musl: the
container job repeats its two runs in alpine:3.22, with the seccomp
helper built static against musl's libseccomp and Alpine's own
bubblewrap, socat and ripgrep. And the newest macOS beside the two
images the macOS legs name, since what Seatbelt and the profile's regex
engine accept has moved between releases.
An embedding application is built into code-split ESM chunks as often as
into one flat file, and a split bundle evaluates modules in another
order with the library in a chunk of its own. The runner now builds the
program both ways, and the program reaches the library through import()
so that it really does sit in a second chunk.

Each executable is copied, with the seccomp helper, into an empty
directory and run from there. An embedded copy has no vendor/, no
node_modules and no package.json beside it, so a lookup that starts from
the library's own location has to find nothing and not matter.

On Linux the program also provokes a refusal, a profile with more deny
paths than bubblewrap takes arguments for, and checks that the error is
still an instance of the exported class and still carries the name and
the code a caller branches on, with every identifier shortened.

Inside a container the program asks for the weaker nested sandbox, as
the container suite does: bubblewrap cannot mount /proc there.
Both container steps piped bun's installer into a shell. They now fetch
the 1.4.2 release archive for the architecture, glibc or musl, check it
against a sha256 recorded here and install the one binary, the way the
ripgrep steps do.
…r names

An application that has the seccomp helper and ripgrep compiled into
its own executable reaches them by running itself under another name:
`seccomp.argv0` with `applyPath: '/proc/self/fd/3'`, fd 3 of every
wrapped command being an open handle on the executable, and
`ripgrep.argv0` with the executable as the command. No suite runs that
arrangement, and it is a path of its own through the wrap: the ARGV0
assignment ahead of the helper, a helper path that is used as given and
never looked for, and no helper file anywhere to find.

The smoke program now plays such an executable. Started as
`apply-seccomp` or as `rg` it runs the real one; and on Linux the runner
runs each build a second time configured that way, checking that the
scan did start it as `rg`, that the wrapped command did reach the helper
through fd 3, and that it ran under the seccomp filter.
ronleizrowice-ant pushed a commit that referenced this pull request Oct 2, 2026
…tarts

`srt` built into a single file with `bun build --compile` exits before
it parses any argument:

    Fatal error: error: Cannot find module '../package.json' from
    '/$bunfs/root/srt-bun'

#571 made `getPackageVersion` load the manifest with
`createRequire(import.meta.url)('../package.json')`, which is resolved
at run time. From `dist/cli.js` that finds the package's own manifest,
but a compiled binary's module lives in Bun's virtual filesystem and
has no manifest beside it, so the require throws. The CLI calls
`getPackageVersion` while it builds the commander program, so every
invocation fails, not only `--version`.

#592's new bundled test compiles a program that imports `dist/` with
`bun build --compile`, which is the library path embedders use. It
never compiles `src/cli.ts`, so it does not reach this.

A static JSON import (`with { type: 'json' }`) resolves the same file
for `tsc`'s output, where `dist/cli.js` still reads `../package.json`
at load time as before, and lets Bun embed the manifest in a compiled
binary. The unused `createRequire` import goes, and the comment says
why the import is static. The no-fallback rule the comment states is
unchanged: a missing manifest still fails rather than printing a
made-up version.

Checked on macOS:
- `npm run build`, `npm run typecheck` and `eslint src/cli.ts` pass
- `node dist/cli.js --version` prints 0.0.77
- `bun build --compile --minify src/cli.ts` gives a binary that prints
  0.0.77 and sandboxes as expected: a write to the home folder is
  refused, the network is closed without an allowlist, and with
  `allowedDomains: ["example.com"]` only that host is reachable
- `bun test` gives the same results with and without the change here
  (1201 pass, 4 fail both ways: three need ripgrep installed, one is a
  3 s timing test)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude and others added 3 commits October 20, 2026 05:24
release.yml is main's: it was rewritten there (pack, verify, publish what
was packed), it already runs the package on the engines floor, and nothing
of this branch's changes to it is carried. `engines` is main's >=22.12.0.

integration-tests.yml keeps both sets of steps: bubblewrap 0.4.1 from main,
then ripgrep 15.2.0 and the Node 22 and 24 steps from here. The Docker legs
keep main's bound-in Node 22.

Joins that no conflict marker shows:

- With @types/node 22 and @types/bun 1.4.2 together, connect() of node:tls
  is any, so seventeen callback parameters in six test files that are new
  on main were implicitly any. They are annotated.
- FileSink.write() may return a promise under those typings, which
  openFdStreams() left floating. It is chained before flush(), so that its
  failure reaches the stream's callback.
- The list of suites rerun on ripgrep 15.2.0 says it is every suite that
  names ripgrep or the scan, less those that do not depend on it. Six such
  suites are new on main: linux-file-above-deny and literal-path-entries
  depend on the scan and join the list, the other four are named as left out.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
One block, in the Ubuntu container step. node and npm are the runner's,
bound in, as on main: the image has Node 18, below what `engines` allows.
bun comes from its release archive by checksum, as here, so the piped
installer and its PATH entry go.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Apart from test:node, which the toolchain pins add: two lines added side by
side would conflict once those are squashed onto main.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
claude and others added 9 commits October 20, 2026 11:04
- A failed write to an inherited fd has a test. Under Bun a chunk too large
  to buffer makes write() itself reject, which main leaves floating and
  reports to the stream as a success. The three ways a write fails now go
  through one async function.
- tls.connect() is typed once, in test/helpers/tls-connect.ts, in place of
  an annotation at each callback. All eighteen suites that dial TLS use it,
  so their options and sockets are checked again. The guard on
  ConnectionOptions moves there.
- The two ripgrep installs share .github/scripts/install-ripgrep.sh.
- `bun test` passes over an argument that matches no file, so the step that
  reruns thirteen suites on ripgrep 15.2.0 first checks that each exists.
- test:node also runs the four other suites that start `node` themselves
  (real-path, default-write-paths, rules-through-links-on-every-runtime,
  tls-terminate-tunnel-ends), which so far only saw Node 22.
- The Node 22 step goes back to what it is on main: `npm test` has already
  run the rest with Node 22 on PATH. The comment no longer promises every
  release line.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
The smoke test:

- On Linux a missing helper or ripgrep fails the run. It used to drop the
  seccomp check and both multicall modes, and exit 0.
- Two checks could not fail. The dependency check came after an
  initialize() that throws for every error: it now reads the warning a
  helper that is not found gives. "The network is closed" connected to an
  outside address, so a host with no route passed: it now connects to a
  listener on the host, which the same words reach from outside the sandbox.
- A failed check throws, so the clean-up runs. A program that cannot be
  started is reported with its reason. The hint that dist/ is not built
  could not be reached, and goes.
- An ARGV0 or SRT_SMOKE_* inherited from the caller's shell no longer decides
  what the program runs as. Host paths are quoted in the wrapped command.

The workflow:

- The Alpine leg runs the seccomp helper the step before it built, static
  against glibc: that is the file the npm package brings to a musl host. It
  used to build another against musl, which nobody ships.
- Lint runs after the build. Without dist/ the rules that read types saw
  `any` throughout test/smoke.
- The two installs of bun share .github/scripts/install-bun.sh, which
  retries. The Alpine script has pipefail for its `tar | tar`.
- macOS 26 is a `label` of its leg, so `matrix.arch` stays an architecture.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
- tls.connect() is untyped because the Bun type package redeclares it in
  'node:tls' with three names that @types/node 22 declares in 'tls' alone.
  src/node-tls.d.ts gives the module those names. The helper, the import
  lines in eighteen suites and the `satisfies` in parent-proxy.ts all go: a
  misspelt option is refused and the result is a TLSSocket wherever it is
  imported, in new code too. Should the file stop working, seven suites
  fail the type check.
- ripgrep 15.2.0 goes on PATH for the second full run, the one on bubblewrap
  0.12.0, in place of a list of thirteen suites kept by hand. Every suite
  now sees it, a new one included, and the step, its list and the check
  that each name exists go. That run varies two tools at once.
- One step installs both releases, so the script is a function in it.
- Comments are cut to what the next reader needs.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
- The smoke test is a suite like any other, test/smoke/bundled.test.ts, as
  tls-terminate-tunnel-ends already is for its fixture. The runner, the
  `test:bundled` script and the CI step go, and the second full run on
  Linux (bubblewrap 0.12.0, ripgrep 15.2.0) now covers it too.
- It needed a tsconfig, an eslint block and an exclude of its own for one
  reason: the type check and lint ran before dist/ was built. `npm run
  typecheck` now builds src instead of only checking it, and lint follows
  it. All three go.
- embedder.ts: one configuration literal instead of two, paths relative to
  the working directory instead of quoted, the multicall part in fourteen
  lines, one header instead of two that overlapped. Every check stays.
- The Ubuntu container takes the runner's bun, bound in like node. That
  leaves Alpine as the only install from an archive, so the script is
  inline there.
- The Alpine step uses the workspace as the step before left it. What is in
  node_modules and dist is JavaScript, so the copy, the second `npm ci` and
  the second build go, about 70 s a leg.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
…s built once

On the machine that built the bundle the library still finds the checkout's
helper, so a bundle that lost the setting passed. The type check already
builds dist/, so the build step after it goes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants