Skip to content

Pinned dependency internals ​

Two dependency stacks and the CLI's WebRTC peer are reached past their public APIs, so their internals are critical and their versions are exact-pinned; a further dependency, the PSI crypto addon, is a vendored local fork. This document records the internal assumptions each rests on, the checklist to re-verify them before a bump merges, and the install-time policies that stand beside them: npm's install-script verdicts, the GitHub Action pin mirror, and the residuals npm's resolver leaves in this workspace. The review requirement itself is in CONTRIBUTING.md; this is the spec-tier complement -- the assumptions and the procedure. What the two container images freeze, pin, and install on top of that tree is in CONTAINER_IMAGES.md.

The upgrade-checklist headings are machine-read. Each Upgrading ... heading below ends in a parenthesised list of the npm packages that section covers, separated by / (the spaces keep a scoped name's own slash intact). npm run check:dependabot-pin-coverage reads those names and fails when one of them is not held out of the batched dependency groups in .github/dependabot.yml, so a package that earns a checklist here cannot ride a routine batch where the checklist is skimmed rather than worked through. It fails on those same names a second way: each must be declared at one exact version by every workspace manifest declaring it, so a checklist cannot outlive the pin its assumptions rest on, nor cover only one of two versions this workspace installs. A new section follows the same heading form; the check's header states what else it reads, and its design note what it does not cover.

That form is for npm packages only, because every name in such a heading is read as one and looked up in the npm groups. A checklist for a pin in another ecosystem therefore has a heading of another shape -- "Bumping the FIPS base image" below is a docker-ecosystem pin, which the update config holds out of Dependabot entirely. Renaming one of those to Upgrading ... fails the check with a message asking for an npm exclude entry that would be false.

Why these are exact-pinned ​

  • SFTP stack (ssh2 / ssh2-sftp-client). The CLI's SFTP adapter (apps/cli/src/connection/ssh2SftpAdapter.ts) drives ssh2 internals past the public ssh2-sftp-client API, so both packages are exact-pinned in apps/cli/package.json. Every bump -- including a security patch -- is then a deliberate edit rather than an npm audit fix that slips in unreviewed.
  • WebRTC stack (peerjs / peerjs-js-binarypack). The web data-channel inbound bound reaches into PeerJS reassembly/unpack internals (apps/web/src/psi/transport/boundedReassembly.ts) and parses the peerjs-js-binarypack wire format directly (packages/core/src/connection/binaryPackBounds.ts). Both are exact-pinned in apps/web/package.json -- peerjs-js-binarypack declared directly, not left a floating transitive, precisely because the bound parses its wire format -- and pulled out of the routine Dependabot batch into a reviewed webrtc-stack group (.github/dependabot.yml). packages/core declares it too, as a dependency at that same exact version, because core's PeerJS framing (packages/core/src/connection/peerjsWire.ts) unpacks the wire with it, and because the marker scan that parses the wire format lives there and its differential test drives the real encoder as its oracle -- so the guard over the shared bound holds whether or not an app keeps the dependency. The CLI's WebRTC transport uses that framing; the web transport decodes through PeerJS itself, plus its own bounded reassembly (apps/web/src/psi/transport/boundedReassembly.ts). The shared WebRTC inbound frame fixtures both transports are held to are built with the real packer in core as well (packages/core/src/connection/webrtcInboundFrameFixtures.ts, published on @alcove/core/testing). Both declarations must move together -- a browser peer and a CLI peer that pack differently cannot exchange a frame, and a core scan or a shared fixture set differentiated against a different version than either app ships is not testing the shipped assumption.
  • CLI WebRTC peer (werift). The CLI drives werift's RTCPeerConnection directly rather than running PeerJS in Node (docs/notes/cli-webrtc-stack.md), so the transport depends on behaviours werift's published API does not state -- and its flushing close reaches past that API into the SCTP association's send queues, because nothing public reports what the peer has actually received. Exact-pinned in apps/cli/package.json, and pulled out of the routine Dependabot batch into the same reviewed webrtc-stack group (.github/dependabot.yml) as the browser half it exchanges with, because the two ends speak one data channel and a bump on either is reviewed against both. The assumptions and the re-verification procedure are in Upgrading the CLI WebRTC peer (werift).
  • PSI crypto addon (@openmined/psi.js). Pinned by construction: an Alcove fork vendored as a local file: tarball (lib/openmined-psi.js-<version>.tgz), whose path resolves to exactly the committed bytes. See The vendored @openmined/psi.js addon.

The vendored @openmined/psi.js addon ​

@openmined/psi.js is vendored as a local tarball (lib/openmined-psi.js-<version>.tgz, a file: dependency), not a registry package, because it is an Alcove fork that ships native N-API prebuilds upstream does not. The prebuilds cover the platforms the CLI deploys on -- Linux x64/arm64 in both glibc and musl (Alpine) variants, macOS, and Windows x64 -- so a Node deployment loads the native backend (roughly an order of magnitude faster than WASM; see PROTOCOL.md), and any platform without a prebuild falls back to the always-correct WASM build. The browser always uses WASM.

Integrity. Two recorded digests cover the vendored bytes, over different things. The committed sidecar lib/<tarball>.sha256 covers the tarball in the tree: the setup action (.github/actions/setup/action.yml) runs sha256sum -c against it before install, and the Alpine leg re-checks it before a native wire-vector verify (.github/workflows/native_alpine.yaml). The release publish job also re-checks it before building the shipped Docker image (.github/workflows/release.yaml, the "Verify vendored native tarball integrity" step, since that job builds from its own checkout rather than the test job's artifact), so a swapped or corrupted tarball fails the build. package-lock.json covers what npm installs: npm records a sha512 integrity for the file: dependency under node_modules/@openmined/psi.js, and resolves that dependency out of its content-addressed cache by that value rather than by re-reading lib/. The lockfile value is therefore part of the trust chain and not bookkeeping -- left stale across a re-vendor, a warm cache reinstalls the bytes being replaced while the sidecar, the marker, and the attestation all pass over lib/, so every control here describes an addon that does not load. Keeping it current is a step of the re-vendor sequence below, and a check rather than that step's discipline is what holds it: scripts/vendored-psi-deps.test.mjs (run by npm run test:scripts, a CI static check) hashes the committed tarball and fails while the recorded integrity is not that value, naming both. What it decides is that the lockfile and lib/ agree, not that a given tree's node_modules holds those bytes -- that is the runbook's own reinstall confirmation.

What that sidecar does and does not establish is the reason for the second control below. It is written in the same Alcove commit as the bytes it describes, so it detects a truncated checkout, a corrupt download, or a stale re-vendor, and it is the only check here that needs no network, no token, and no tooling beyond sha256sum. It cannot bind those bytes to the build that produced them: a writer who can edit lib/ edits both files. It is an availability and accident control, not a tamper control across the fork boundary.

Provenance. The bytes are built by another repository -- the fork's native-prebuilds.yml -- and contain native .node addons dlopen'd with full process privilege into the PSI pipeline, so the claim that has to hold is one no Alcove commit can forge. That claim is a GitHub artifact attestation, minted by actions/attest-build-provenance in the producing workflow and stored against the producing repository. npm run check:prebuild-provenance (scripts/verify-prebuild-provenance.mjs) reads it, running in the setup action ahead of npm ci and in the release publish job ahead of the image build. Rationale and the mechanisms weighed against it: prebuild-provenance.md.

Each vendored tarball has a marker beside it, lib/<tarball>.provenance.json, which records the identity to verify against and arms the check:

FieldMeaning
attestation_expectedfalse -> report and pass, leaving the sidecar as the whole control; true -> verify, and fail on any non-zero verifier exit
artifactThe tarball's filename, which must be the one vendored in lib/
sha256The tarball's digest, held against the real bytes in both arming states, offline
producer_repository<owner>/<repo> the attestation is stored against
signer_workflow<owner>/<repo>/<path>/<to>/<workflow> of the workflow whose identity signed it
source_refThe producing repository's git ref. Required once armed
source_digestThe producing repository's commit, 40 hex characters. Required once armed

Three values reach the verifier's argv as free text -- signer_workflow, source_ref, and the vendored tarball's own filename, which is the path being verified -- and each is held to a whitespace-free class wherever it appears rather than only once armed: [A-Za-z0-9_./-] for the two marker fields, and that class without / for the filename, a single entry in lib/. The reason is the failure-cause recognizer below, which reads the verifier's stderr by substring: every marker it holds contains a space, so a value containing one (refs/heads/no route to host) would rename an identity mismatch as an outage on any failure path that echoes the value back.

Whether gh echoes these three anywhere is unmeasured from here, and no such echo is claimed. Driven with sentinel values, gh attestation verify 2.98.0's 401 and 404 renderings echo the repository, the artifact digest, and the predicate type, and none of the three; reaching a policy evaluation, where a mismatched identity is reported, needs egress this repository's fenced measurements did not have. The constraint is cheap fail-closed hygiene over free text a file under lib/ supplies, not a measured exploit closure.

Armed, the check runs exactly:

sh
gh attestation verify lib/<tarball> \
  --repo <producer_repository> \
  --signer-workflow <signer_workflow> \
  --source-ref <source_ref> \
  --source-digest <source_digest> \
  --predicate-type https://slsa.dev/provenance/v1 \
  --deny-self-hosted-runners

--predicate-type is stated rather than left to the verifier's default so a change to that default cannot widen what counts as a provenance claim, and --deny-self-hosted-runners holds the fork to the GitHub-hosted runners every leg of native-prebuilds.yml declares. gh attestation verify queries api.github.com at verification time, so both call sites pass GH_TOKEN; the offline sidecar is what stands when that API does not answer. That is the disarmed state's baseline, not a fallback available while armed: once a marker sets attestation_expected: true, an unreachable or failing gh is a failed verification, and the job goes red rather than quietly settling for the sidecar.

api.github.com is not the whole of what verification reaches: gh also fetches the Sigstore bundle from its blob host and the trust root from the TUF CDN. An environment fenced from either fails closed, so a red check there is not evidence about the attestation. What the check reports for it is separated from the lookup's own answer, because the no-attestation / identity-mismatch conclusion is a claim about the vendored bytes and only a completed lookup earns it. Four cases are named as themselves rather than folded into that conclusion: a verifier that could not be started at all, a run a signal ended before it concluded, a run the runtime reports an error for, and a non-zero exit whose output names a network failure or a missing or rejected credential. An errored run is refused whatever it exited with, a zero status included -- output past the check's stderr capture limit is reported as ENOBUFS against a run that keeps whichever status or signal it ended on, and the exit of a run cut short is not a verdict. Recognition of the last group is by substring against the shapes gh attestation verify was measured to produce -- an unreachable trust root among them, whose measured rendering, no valid Sigstore verifiers could be initialized, has no network wording of its own. It is best effort rather than a partition, and the trust root is where that shows: gh has more than one rendering for a fenced trust root, and one other than the measured line is not recognized. An unrecognized shape takes the fallback, reported as the lookup's own answer with the verifier's own output -- printed above the check's line -- as the authority on the cause. Every one of these causes fails the check regardless. The armed check's end-to-end pass is therefore established only where that egress exists -- CI's own run of it, or a maintainer host -- and a network-fenced dev container can exercise the offline half (marker, digest binding, sidecar) but not the lookup.

The marker is required in both arming states, and a missing, malformed, or digest-mismatched marker fails the check. So disarming is not an absence: it is attestation_expected flipped to false in a tracked file under lib/, which a reviewer reads in the diff. Re-pointing the marker at substituted bytes passes the offline digest binding and is exactly what makes the armed attestation lookup fail, since no attestation exists for those bytes. What neither control establishes is that an attested build is what it claims -- an attestation binds bytes to a run in the producer repository, and whether that run built the source it names is the fork's property, not this repository's.

The native_alpine.yaml and image_smoke.yaml legs re-check the sidecar only. They consume the tarball an Alcove CI run already verified rather than introducing it, so they hold the offline check for the stale- or corrupt-checkout case and do not repeat the network one.

The worker-teardown fix. The fork has a fix, in every artifact from 2.0.6-seclink.2 onward (which release first carries it, and the fork's and upstream's manifests as described here, are fork and upstream history outside this repository and were unverified as of 2026-09-29), for a worker_threads teardown segfault: running any masking op inside a worker and then tearing the worker down crashed the whole process (exit 139, which a worker-thread segfault is not contained below). The cause was that BoringSSL lazily initializes per-thread state whose __cxa_thread_atexit destructor fired after the N-API environment was already torn down. The fork's fix is a napi_add_env_cleanup_hook (in the fork's private_set_intersection/napi/psi_napi.cpp) that calls OPENSSL_thread_stop() to release that per-thread state while the environment is still alive. Without it, running the PSI crypto off the event-loop-owning thread -- the CLI's worker offload -- would segfault at the end of every native-backend exchange; with it the worker tears down cleanly, which is what lets the offload use the native backend at all rather than pinning the worker to WASM. The apps/cli real-worker integration test (test/integration/psiWorkerRealWorker.test.ts) is the regression guard: a reintroduced segfault crashes the test process rather than letting it assert.

Terminating a worker during a masking call. The teardown fix covers a worker torn down between operations, not one torn down inside a native masking call: terminating the PSI worker during a native crypto call aborts the process (measured 2026-10-02 at 2,000,000 records, an interrupt exiting 134 instead of 130). The worker is therefore never terminated inside a call:

  • Each masking operation runs in chunks (psiChunks.ts sizes them), and a match is fed the response in pieces the same policy sizes. Between chunks and pieces the worker reads a stop flag the host sets in shared memory. A set flag fails the operation there instead of running the next chunk or piece.
  • Disposing a worker-backed engine sets the flag, and the CLI's worker handle defers terminating the worker until the request in flight replies. The wait is therefore at most one masking chunk during encryption, and one response piece during the match; a match whose response is one piece, such as one at or below the chunk floor, is waited for in full.
  • The CLI's SIGINT and SIGTERM handlers dispose every live PSI worker and wait for each to exit before exiting, so an interrupt during the crypto exits 130 (143) after that wait. The wait has no timeout; the operator bounds it. When a request is in flight the handler prints that it is finishing the current step and that pressing Ctrl-C again or sending the signal again exits at once, and a further SIGINT or SIGTERM during the wait exits 130 or 143 at once without waiting for the workers. That exit stops the worker mid-call, so it can end with a different exit code (the abort above).
  • A lost partner sets the same flag (WEBRTC_TRANSPORT.md).

The stop at a chunk boundary and at a match's response piece boundary in both match modes, and a match in one piece running to its end before a stop takes effect, are held by packages/core/test/psi/psiWorkerStop.test.ts; the deferred termination and the exit on a repeated signal by apps/cli/test/unit/psiWorkerHost.test.ts, and the exit status against the built CLI by apps/cli/test/stress/interruptDuringEncryption.stress.test.ts, which sends Ctrl-C through a terminal during masking on repeated attempts. Where the runtime has no SharedArrayBuffer (a browser page that is not cross-origin isolated), no flag is sent, and a step whose partner is lost runs to its end.

The streamed match's refusal texts. Alcove names three of the streaming match's refusals of a partner's setup by the engine's status text -- a setup out of strictly ascending order, one that is not a single Raw data structure, and one that ends before its first byte -- and reports each as a named protocol error (packages/core/src/psi/psiEngine.ts). packages/core/test/psi/psiEngineStreamedMatch.test.ts holds the mapping on both backends, so a re-vendored engine whose text differs fails there; any other refusal is reported as the setup failing to decode.

The engine's failure messages. From 2.0.6-seclink.6, an engine failure throws an Error holding the engine's own status message, on the WebAssembly build as on the native addon, and Alcove keeps that message as the cause of the frame it reports as failing to decode, where the operator reads it. packages/core/test/psi/psiWorkerEngine.test.ts holds this on both backends, in process and across the worker boundary, so a re-vendored engine whose WebAssembly build loses the message fails there.

Runtime dependency surface. The fork declares exactly two runtime dependencies: google-protobuf, whose jspb runtime backs the psi_pb wire types and whose package the shipped type declarations import, and node-gyp-build, which psi_native_node.js requires to resolve a prebuild. Upstream additionally declares a gRPC / protoc codegen set (@grpc/grpc-js, protoc-gen-js, protoc-gen-ts, ts-protoc-gen) as runtime dependencies, and nothing the tarball ships loads any of it -- the published entry points require only crypto/fs (psi_wasm_node.js), url (psi_wasm_web.js, psi_wasm_worker.js) and node-gyp-build. Declared as runtime dependencies they pulled @grpc/proto-loader and protobufjs into every Alcove install, and ran protoc-gen-js's postinstall, which downloads a protoc plugin binary and unpacks it with adm-zip -- an advisory with no available fix, reaching Alcove by no other path. The fork has the three protoc packages as devDependencies instead, so they serve its own bundle build and reach no consumer, and declares @grpc/grpc-js nowhere in its manifest at all. Note google-protobuf and protobufjs are distinct libraries; the PSI path uses jspb from the former, so removing the latter does not perturb it. scripts/vendored-psi-deps.test.mjs holds this line as a CI check rather than as prose: it asserts against the committed lockfile that the vendored package declares only those two packages and that no gRPC or proto-codegen package appears anywhere in the tree, so a re-roll that regresses the fork's manifest fails red instead of silently restoring the surface.

Rebuilding or bumping. The addon is built from the fork's own Docker build image, not from this repo. On any rebuild -- a fork change, an upstream merge, or a version bump -- verify the new bytes' provenance first, then rewrite the provenance marker for them, and only then regenerate the sidecar (sha256sum <tarball> > <tarball>.sha256), so a digest is never written over bytes whose origin was unchecked. The lockfile is then brought onto the new bytes, in order: compute their sha512, edit the integrity field for node_modules/@openmined/psi.js, force the reinstall (npm skips it while the version string is unchanged), and confirm the installed prebuilds are the ones the tarball extracts to. That confirmation is normative rather than belt-and-braces: it is the only step here that reads what npm actually installed. The ordered procedure and the reviewer's own steps are the runbook, PREBUILD_REVENDOR.md (the lockfile and reinstall commands are its step 5). A rebuild that touches the native crypto is crypto-code review scope (see CONTRIBUTING.md).

Inlined dependencies and their remediation path ​

A dependency inlined into a built artifact is not resolved at runtime, so a transitive bump does not reach it: remediating an advisory against one requires bumping the dependency and rebuilding and re-releasing @alcove/core. Which dependencies that covers is therefore an upgrade-path fact, not a build detail.

@openmined/psi.js and canonicalize are inlined into every build. canonicalize is the RFC 8785 serializer behind CANONICAL_ENCODING.md. It is inlined because it is an ES module with a default export and the CJS build uses rollup's default interop, which treats a require() result as the default export itself. Driven on 2026-10-02 with Node 26.10.0, canonicalize 5.1.0 and rollup 4.63.4:

  • require("canonicalize") loads: the package's exports carries a default condition and Node 26 loads an ES module through require. It returns the module namespace (__esModule and default keys), not the function.
  • With canonicalize held external, the CJS bundle loads, and the first canonicalString call fails with canonicalize is not a function.
  • Adding interop: "compat" to the CJS output makes the external build work, but changes how every external is imported, so the single-function package stays inlined instead.

Moving it to external is therefore a build-configuration change, not only a list edit, and is re-driven the same way before it is made.

re2js and yaml are not inlined: the ESM and CJS builds keep them external, so a transitive bump does remediate them for the CJS-based CLI.

The install-script policy (allowScripts) ​

The npm behaviors in this section and in npm resolution residuals that carry a 2026-10-01 date were re-driven that day against npm releases installed from the registry (11.14.1 through 11.19.1), naming the releases each was driven on. A statement carrying another date, or none, was measured earlier and not re-driven.

The root package.json has an allowScripts map. It is npm's own install-script policy field (npm 11.16.0 and later, maintained with npm approve-scripts and npm deny-scripts; from 11.18.0 npm's messages name npm install-scripts approve and npm install-scripts deny instead, and 11.19.1 accepts both spellings), not a local convention: npm reads it from the package.json at the install prefix -- so the root map governs every workspace -- and consults it per installed package before running that package's preinstall, install, or postinstall (and prepare, for a non-registry source). A registry package is identified by the name and version parsed out of the lockfile's resolved tarball URL, not by the tarball's own manifest, which its publisher controls, and not by its install directory, which for an aliased dependency ("h3-v2": "npm:h3@2") has a name the registry never published. The install-directory half was re-driven 2026-10-01 on 11.17.0 and 11.19.1: a dependency declared as "cj": "npm:[email protected]" is covered by a [email protected] key and not by a cj key.

Enforcement status. A false verdict blocks that package's install script: the install still succeeds, the script does not run, and whatever it would have produced is absent from the tree. What it does to the package's bin links depends on the release: measured 2026-10-01 with [email protected] denied, 11.16.0 and 11.17.0 create no .bin link for it, while 11.18.0, 11.19.0 and 11.19.1 create one. None of the denied packages declares a bin today. A package with no verdict npm would merely report, running its script and printing it as not yet covered by allowScripts. The root .npmrc sets strict-allow-scripts=true, which makes that a refusal instead: the install fails with ESTRICTALLOWSCRIPTS, naming every uncovered package, before any of their scripts run (measured 2026-10-01 on 11.17.0 and 11.19.1 with two uncovered packages: both are named and no node_modules is written). Recording the missing verdict (npm approve-scripts to allow, npm deny-scripts to block) is what clears it.

npm reads the flag from the .npmrc at the install prefix, so it covers a local install, CI's, and the image build alike -- the Dockerfile copies the root .npmrc into the builder beside the lockfile, ahead of both of its npm ci invocations, which would otherwise install under npm's defaults with the flag absent. That makes the file an image build input on the same footing as the lockfile, which is why image_smoke.yaml's path filter lists it. The file may therefore hold configuration only: registry credentials belong in the user-level ~/.npmrc, which no build reads. Setting the flag at all is safe only because the map is already complete against the committed lockfile; the --omit=dev production install is covered by the same map, since npm objects to an uncovered script rather than to a verdict for a package the pruned tree no longer installs (measured 2026-10-01 on 11.19.1: the Dockerfile's npm ci --omit=dev --omit=optional -w packages/core -w apps/cli installs clean under the committed map).

The flag is not install-only. From npm 11.17.0, npm exec runs the same preflight against the config at the prefix it resolves, so an npx --yes <package> invoked from inside the repo fails when the fetched package declares an install script, rather than running it; 11.16.0 runs the script. A verdict in the prefix's allowScripts does not clear that refusal. Measured 2026-10-01 with npm exec --package [email protected]:

  • From a prefix holding the flag, it fails ESTRICTALLOWSCRIPTS with no verdict and with an "[email protected]": true verdict alike, on 11.17.0 and 11.19.1.
  • From a copy of this repository's root and of its apps/cli, it fails the same way on 11.19.1.
  • The one-off --allow-scripts=<pkg> the message names clears it on 11.19.1 (--allow-scripts=esbuild and [email protected] each do), and running from a prefix without the flag clears it on 11.17.0 and 11.19.1.

Nothing the repo does today hits it.

What holds the image to it, and what does not. scripts/dockerfile-freeze.test.mjs holds the COPY that puts the root .npmrc in the builder to its exact reviewed text and requires it ahead of the first npm ci, so the file cannot be dropped from the build or a second one land on the name. That is a text comparison rather than a model of where a COPY's sources land: Docker copies a directory source's contents, so a file can arrive without the instruction naming it, and this does not reach that.

Nothing in the image verifies at build time that the policy is actually in force when npm runs. What the file states is not yet what npm will do -- npm resolves its command line and environment ahead of the project .npmrc, so an npm_config_-prefixed variable in the build environment, an install flag, or a base image with its own configuration can turn the refusal off without this file changing, and the build would not notice. Measured on npm 11.17.0, and re-driven 2026-10-01 on 11.17.0 and 11.19.1 with each set through an npm_config_ environment variable, npm config get strict-allow-scripts answers true while the refusal is off underneath it under both dangerously-allow-all-scripts=true and ignore-scripts=true, so a config read is not a substitute for that verification either. So the flag's reach in the image is the same as its reach anywhere else: it holds when nothing overrides it, and nothing here detects an override.

What the committed .npmrc does close is the user-level route. It states ignore-scripts=false and dangerously-allow-all-scripts=false -- npm's own defaults -- because the project file outranks a user-level ~/.npmrc (measured on npm 11.17.0, and re-driven 2026-10-01 on 11.17.0 and 11.19.1 with the user file passed as --userconfig: with the project file stating both, a user file setting both to true is overridden and the refusal still fires, while with the project file stating only the flag the same user file turns it off). The environment outranks both, which no line in this file can reach: on the same releases that day, either variable above turned the refusal off with the project file stating both.

The credential check is byte equality with a literal held in the freeze test: any edit to the file at all -- a key, a value, a comment, a line ending, an encoding -- reddens it until that literal changes in the same diff. It is not a reader that decides which of the file's lines are credentials, because such a reader has two gaps equality does not. npm authenticates from more of its config surface than its _auth-shaped keys -- an inline cert/key PEM pair is presented to the registry as an mTLS client credential, and a registry-scoped certfile/keyfile names a file holding one, neither under a secret-sounding name. That surface grows with npm rather than with this repo, so no enumeration of credential-named keys stays complete. Any such reader must also agree with npm's own parser about which bytes are a line at all: npm parses with the ini package, which breaks lines on /[\r\n]+/, so a lone CR inside what looks like a comment ends the comment and starts a setting npm honors. The cost of equality is one test edit whenever the file legitimately changes -- a diff in which the review reads the bytes rather than a verdict about them.

Two properties of that literal are checked beside the equality, covering the one path equality does not: an editor who changes the file and the literal together. It must contain no CR, which is what makes the check that it states the four policy lines and nothing else an enumeration of the same lines npm reads. And it must contain no URL userinfo (https://user:secret@host/, including under an @scope:registry key), which npm turns into an Authorization: Basic header for that registry -- no policy key takes a URL, so that is what stands over the value of a URL-valued key later joining them. A credential reaching the builder any other way -- a base image's ~/.npmrc, a build secret, an npm config set in a RUN -- is outside all of it, as is an uncommitted one: the check and docker build read the same working tree, so it speaks for the committed file only on a clean checkout.

The file is worth binding at all because the release and smoke builds export every builder layer to a shared cache (cache-to: type=gha,mode=max), so a credential copied into the builder is cached rather than discarded with the stage.

The npm floor. The install-side policy arrives in npm 11.16.0, the release that follows 11.15.0 in the registry's version list. Measured 2026-10-01:

  • Against this repository's own tree, 11.16.0 installs clean with the committed map and fails ESTRICTALLOWSCRIPTS naming [email protected] when that verdict is removed, as 11.17 does.
  • 11.15.0 and 11.14.1 have none of it, so both are inert there: a script a false verdict denies runs, with no diagnostic about the map, and the flag draws only npm warn Unknown project config "strict-allow-scripts" (and, from the committed .npmrc on 11.15.0, the same warning for dangerously-allow-all-scripts) -- warnings about the keys, never about an install script that ran unreviewed. On 11.15.0 this repository's tree installs clean with the esbuild verdict removed.

The builder's floor is nonetheless 11.17 in intent, for two reasons that are not "anything lower is inert": 11.17 is the npm every matching rule in this section was measured against (re-driven 2026-10-01 on 11.17.0 and 11.19.1), and 11.17.0 is the first release to run the same preflight from npm exec. Nothing in this repo enforces that floor, in the image or outside it. engines.node constrains node, not the npm shipped beside it, so an install driven by npm 11.15.0 or earlier runs with the policy silently off wherever that npm comes from -- including a builder whose base digest is re-pinned onto an older node. Which npm the pinned digest bundles is therefore a property of the base image: the one pinned here bundles 11.19.1, measured by running the pinned digest. The digest itself is held as a literal in scripts/dockerfile-freeze.test.mjs, once per stage, so moving it is an edit to that literal in the same diff rather than a silent re-pin -- what no check reads is which npm the digest moved to bundles.

The measured bundled-npm claim is about the default Dockerfile only (the digest-literal freeze covers both images). The FIPS variant's builder installs no distribution node package: its npm arrives inside the nodejs.org tarball named by ARG NODE_VERSION, whose bundled npm the build prints (npm --version) without asserting. So the npm that variant installs under is a property of that tarball, and nothing checks which version of npm is inside it. Moving ARG NODE_VERSION alone does not silently move it, though: no hash is committed for the release an override names, and the bytes it fetches will not match the hash committed for the pinned release, so the override fails the build rather than installing under an unnoticed npm. The floor is unenforced in both builders; what differs is only which artifact decides it.

The key forms npm matches. Measured 2026-10-01 on npm 11.17.0 and 11.19.1 against [email protected]:

  • For a registry package npm honors a bare name (equivalently name@*), one exact version, and exact versions joined by ||.
  • A semver range or a dist-tag is dropped from the policy with one warning.
  • A lone version must be spelled canonically: v1.0.0, =1.0.0, a leading zero, or build metadata is kept in the policy and matches nothing, with no diagnostic at all.
  • Inside a || disjunction a leading v and build metadata are accepted and match, while a disjunction holding =1.0.0 or a leading zero is dropped whole, with the same one warning a range draws.

Either way an entry npm does not match states a verdict that is not in force.

A non-registry source -- a file: tarball or directory, a git URL, a remote tarball URL -- is matched by its resolved spec rather than by any name, so no name key reaches it. Whether a source counts as registry is decided by the specs its consumers wrote, not by the shape of the URL it resolved to. The override cases below are the evidence (re-driven 2026-10-01 for a file: tarball and a registry-shaped remote URL); a key spelled as a resolved spec, and a git source, were not driven.

Why the allow and deny entries are pinned differently.

  • Allows are pinned to an exact version (esbuild, ssh2, cpu-features). The approval is a review of one version's script, so it must not extend to the next one: a bump arrives with no verdict and has to be reviewed rather than inheriting the answer.

  • Denials are name-only (unrs-resolver), which covers every version. It is a dev-only native build unreachable from the shipped image, so no future version of it needs a fresh review to stay blocked. Under npm 11.16.0 or 11.17.0 a version that added a bin would lose its .bin links along with its script, which is the one change that would need a fresh look; from 11.18.0 it keeps them (see Enforcement status above).

  • fsevents is denied at the one installed version ("[email protected]", at the root, a darwin-only optional dependency of rollup, tsx and vite). Nothing runs either way: the published tarball declares only clean, build, test and prepublishOnly and ships no binding.gyp, so the node-gyp rebuild an install would run does not exist -- it sits in build. Re-driven 2026-10-01:

    • The tarball, unpacked from npm pack, holds exactly that script set and no binding.gyp.
    • The lockfile's hasInstallScript: true matches the flag the registry's abbreviated packument carries, while the registry's full version metadata (what npm view [email protected] scripts reads) lists an install: node-gyp rebuild the tarball does not hold.
    • Where npm has only that flag it prints the sentinel (install scripts present) rather than a script body, as it does for esbuild under npm ci.
    • On linux npm skips it as platform-incompatible, and with the verdict removed the committed tree installs clean on 11.19.1. With npm's own --os=darwin --cpu=arm64 the refusal fires, naming [email protected] (install: (install scripts present)).

    The verdict is what makes the map complete against what the lockfile records: a flagged package with no verdict is what --strict-allow-scripts refuses on a platform that installs it, whether or not a script exists to run.

The map is held in step with the lockfile by a check, not by prose. scripts/allow-scripts-policy.test.mjs (run by npm run test:scripts, a CI static check) reads the committed package.json and package-lock.json and fails on an entry in a form npm does not match, an entry matching no package the lockfile installs, a lockfile package with an install script and no verdict, or a key or overrides form the check does not model. A dead entry draws no npm diagnostic at all and an unmatchable spelling draws one only sometimes, which is why both are pinned here: a bump that adds an install script, or moves one to a version no entry names, reddens npm run test:scripts instead of silently widening the policy.

The check and the flag act at different moments, which is why the flag earns its place beside a check that already holds the map complete. CI installs dependencies in the setup action and reaches npm run test:scripts several steps later, so the check names an uncovered install script only after the runner has already executed it; the flag grounds that install before it runs. The check's own job is the part npm never diagnoses -- a dead or unmatchable verdict, and completeness read from the committed lockfile rather than from whatever tree an install happens to build.

What the check holds, and what it does not. npm decides from a live tree and this check from the committed lockfile, so the check is not npm's own unreviewed set and does not try to be. npm's --strict-allow-scripts preflight walks the ideal tree, its post-install advisory walks the actual one, it reads each package's source from dependency edges an overrides entry rewrites, and its script enumeration reads each extracted package.json from disk. What the check holds is the lockfile-level property: every verdict in the map is one npm would match against the committed lockfile, and every install script that lockfile records has one.

Where the lockfile cannot answer, the check refuses the input rather than guessing, failing red and naming what has to be modeled: an overrides form outside the one below, and any key npm-package-arg does not read as a package name -- a file: path, a tarball URL, a git spec, a bare owner/repo. Matching those means canonicalizing a source the way npm does, and refusing is what keeps the check from reporting a verdict npm enforces as a dead key to delete.

What the check models about a root overrides entry. An override rewrites the dependency edges npm reads each package's source from, and the lockfile records neither the override nor the rewritten spec -- every dependent entry keeps the range it declared. The source class a verdict is read against therefore comes from the override's own spec, read in the flat "<name>": "<spec>" form and the one nested form below:

  • A comparator-set semver range -- comparators (5.0.8, ^5.0.8, >=5.0.8 <6.0.0, 1.x, *) optionally joined by || -- leaves the identity model untouched: the lockfile records the tarball npm resolved the override to, and npm decides the verdict at that version. Measured 2026-10-01 on npm 11.17.0 and 11.19.1, an override moving the @parcel/watcher that [email protected] declares at ^2.5.6 (resolving to 2.6.0 unoverridden) to 2.5.4 fails the install under a verdict keyed at 2.6.0 or at 2.5.6 (ESTRICTALLOWSCRIPTS, naming 2.5.4) and installs clean under one keyed at 2.5.4, so a verdict naming the version the dependent's range would resolve to is a dead key. That grammar is narrower than the specs npm resolves from the registry, and the gap costs a refusal rather than a verdict: a hyphen range ("1.0.0 - 2.0.0") and a v-prefixed version ("v2.0.1") are ordinary registry spellings npm honors. Measured 2026-10-01 on npm 11.17.0 and 11.19.1, each resolves the brace-expansion under [email protected], to 2.0.0 and 2.0.1 respectively, and each reddens the check as an unmodeled spec, whose message names modeling the form here as the way through. Refusing is the idiom rather than a gap to widen away: an unmodeled spelling costs a red check, never a verdict reported as in force.
  • A source spec -- a scheme, a path shape, or the bare owner/repo shorthand npm treats as a git host or a directory -- takes every name-keyed verdict for that name out of force. The check names such a package as unreviewed when the lockfile records an install script for it and passes it when it does not. Measured on npm 11.17, and re-driven 2026-10-01 on 11.17.0 and 11.19.1 with the core-js under [email protected] overridden, a transitive dependency overridden to a file: tarball, or to a remote tarball URL spelled in the registry's own <name>/-/<name>-<version>.tgz shape, is reported uncovered under a name-keyed true and a name-keyed false alike, exactly as with no verdict at all. The lockfile entry npm writes has that URL in resolved, though, which the identity model would otherwise read a name and version out of.
  • A nested per-parent object whose every spec is a comparator-set range ("vitepress": { "vite": "6.4.3" }) rewrites only the edges under that parent, and leaves the identity model untouched for the same reason as the flat range. Measured 2026-10-12 on npm 11.20.0, that override fails the install ESTRICTALLOWSCRIPTS naming the [email protected] vite 6.4.3 resolves to, and installs clean under a verdict keyed at 0.25.12.
  • Every other form is refused in the register of the key-shape refusal above: a nested object holding a source spec or any other unmodeled spec, a "." self key and a "name@range" key each rewrite some edges on a name and not others, which the lockfile cannot answer. A dist-tag, an npm: alias and a $name reference to a root dependency each name a source this does not model.

Three residuals sit under that model, all of which cost a refusal rather than a verdict:

  • A git source is grouped with file: and remote unmeasured, for want of a reachable git host to measure it against.
  • The check keys an override both to the name a package is installed under and to the name its resolved URL holds, since either can be the name the rewritten edge names. Whether npm's own matcher follows an aliased edge ("h3-v2": "npm:h3@2") to an override keyed on either name is unmeasured, so the check can refuse a verdict npm holds.
  • Nothing verifies that the committed lockfile is the one npm resolves with the overrides in force: npm leaves a lockfile whose existing resolution already satisfies its dependents byte-identical when an override is added (re-driven 2026-10-01 on 11.17.0 and 11.19.1; What a root overrides block changes about later installs records the route around that). An override that has not been applied is treated as covered. Where the lockfile already holds what that override resolves to, npm ci installs that same lockfile and what runs is what the check read -- measured 2026-10-01 on 11.17.0 and 11.19.1, a brace-expansion override standing over a lockfile resolved without it installs clean under the committed map. Where the lockfile lacks a package the unapplied override needs, npm ci refuses rather than resolving one: on both releases that day, an override of "brace-expansion": "^1.1.11" over the committed lockfile fails EUSAGE (Missing: [email protected] from lock file, followed by [email protected] and [email protected]), so nothing installs and no unreviewed script runs.

The rest are limits rather than refusals:

  • A package whose tarball ships a binding.gyp and declares no install script gets a synthetic install: node-gyp rebuild that npm discovers only after extraction. Its lockfile entry has no hasInstallScript flag, so a lockfile-level check cannot name it -- and neither can npm's own pre-extract preflight, which lets that script run under --strict-allow-scripts exactly as without it. Re-driven 2026-10-01 on 11.19.1 for a file: tarball only: one holding a binding.gyp and no scripts had its node-gyp rebuild run under the flag, where the same tarball declaring an install script is refused. A registry package of that shape was not driven.
  • A stale node_modules holds packages the lockfile no longer installs, and npm's advisory reports those: npm approve-scripts --allow-scripts-pending (from 11.18.0 also npm install-scripts ls) can list packages the check is silent about -- on 11.19.1 on 2026-10-01, a core-js dropped from the manifest and the lockfile but left in node_modules is listed. A clean install reconciles the two.
  • A denial whose package stops declaring an install script keeps matching it, so nothing reddens. That is deliberate for npm 11.16.0 and 11.17.0, where a false verdict still costs the package its bin links, so requiring every entry to govern a script would demand deleting verdicts npm still enforces. From 11.18.0 a denial leaves the bin links in place (see Enforcement status above), so on those releases a denial with no script to govern changes nothing npm does.
  • A dependency a dependent's own manifest introduces from a git or remote-URL source is one npm refuses name-keyed verdicts for while its lockfile entry can look like a registry install. The override model above reaches only the ones the root manifest introduces; for the rest the lockfile records a spec the check does not read. None exists here, and adding one goes through the dependency review in CONTRIBUTING.md.
  • The rules above were measured against npm 11.17 (re-driven 2026-10-01 on 11.17.0 and 11.19.1) and are modeled here rather than re-derived from the npm in use, so a release that changes npm's matcher needs them re-measured.

What the dead-key rule costs. Requiring every entry to match a package the lockfile installs means the map cannot hold a standing "never run this package's scripts" verdict for a package absent from the tree. strict-allow-scripts recovers most of that: a package arriving with no verdict fails the install and its postinstall does not run, where an unset flag would have let it install, warn once, and run -- so the install stops and waits for a review rather than holding an answer recorded earlier. For protoc-gen-js, the one package such a standing denial would cover, the protection lives in a stronger control regardless: scripts/vendored-psi-deps.test.mjs fails if that package appears anywhere in the committed lockfile at all, which no install-script verdict does.

GitHub Action pins and the composite mirror ​

Every action a workflow uses is pinned by ref (actions/checkout@v7, docker/build-push-action@v7, sigstore/[email protected]). The github-actions block in .github/dependabot.yml is configured against .github/workflows, and its ignore entries hold three orgs (actions/*, docker/*, github/*) to major bumps only, so a within-major re-pin does not open a pull request. cosign-installer is absent by design from that list, so its patch bumps show up.

Two invariants keep that configuration accurate, each held by a check rather than by this document: the composite mirror below, and the ignore list against the pin shapes it covers.

The composite mirror ​

The shared CI prologue composite (.github/actions/setup/action.yml) pins actions of its own, on a path this repo does not rely on being scanned. The invariant that covers it is a mirror rather than a second ecosystem block:

Every action pin under .github/actions is identical to a pin under .github/workflows. A release or advisory then shows on the workflow occurrence, and the bump answering it cannot land there and leave the composite stale -- which makes the composite occurrence transitively tracked.

scripts/check-action-pin-drift.mjs (npm run check:action-pin-drift, a CI static check in static_checks.yaml) holds it rather than prose. It parses every workflow and every action.yml with the yaml package and walks each document for uses: values (skipping ./ local and docker:// references, which name no remote action). It fails on three conditions: an action named in both trees with differing refs anywhere it appears, a composite pin naming an action no workflow uses, and a remote reference naming no ref at all. The second is what keeps the mirror from being satisfied vacuously -- a composite-only action has no occurrence on the configured path at all, so it fails closed and the decision (mirror the pin into a workflow, or extend Dependabot coverage to .github/actions) is made explicitly rather than by omission. The third closes the same gap one layer down: an unpinned reference (uses: actions/setup-node, a trailing @ with nothing after it, or a leading @) fixes no version. Nothing in the repository then determines which code the step runs and no release or advisory has an occurrence to show on -- it cannot stand as either side of the mirror. Whether GitHub itself rejects that shape is unverified, and the rule does not rest on it: if GitHub does, the rule never fires.

The comparison is textual. @v7 agreeing with @v7 says nothing about what either tag resolves to, so a floating major moving under both occurrences is outside the check, as is anything about which paths Dependabot in fact scans -- it enforces this repo's mirror invariant and confirms no tool's coverage. It reads uses: references only, so an action reached another way is invisible to it, and its ref-agreement rule binds only actions appearing in both trees.

The ignore list and the pin shapes it covers ​

An ignore entry suppressing within-major updates is only sound over pins that float within their major. An exact pin (actions/[email protected]), a commit sha, or a branch name under a covered org would sit under an ignore that suppresses every update it could ever receive: frozen silently, with no pull request to include a fix that lands within the major.

scripts/check-dependabot-ignore-shape.mjs (npm run check:dependabot-ignore-shape, a CI static check in static_checks.yaml) holds that rather than prose. It reads the ignore entries out of the github-actions block -- the org list is not restated in code, so editing the config changes what is enforced -- and fails on any pin under .github/workflows or .github/actions whose name an entry covers and whose ref is not a bare v<major>. An entry counts as suppressing within-major updates when its update-types names version-update:semver-minor or version-update:semver-patch, or names no update type at all; one that suppresses neither imposes no shape requirement, and an entry naming an org with no pins in either tree is not a failure. The reference extraction is the pin-drift check's, so a reference naming no ref is that check's rule C rather than a second report here.

The rule is a coherence property of this repository's configuration, not a prediction of Dependabot's behavior. Whether a version-update:semver-minor / semver-patch ignore does in fact suppress a v7.0.1 -> v7.0.2 bump has not been driven against the real tool, and the check does not rest on it: a pin the config's own stated rationale assumes to be floating is worth holding to that shape either way. Two directions are outside it -- a bare-major pin from an org no entry names (the ignore list's completeness, which fails noisily rather than silently), and what a ref resolves to, since the ref is read as text.

Open assumption: dependency-name: "github/*" and subpath actions. Whether that pattern covers github/codeql-action/init is unsettled in two parts: whether the glob crosses / at all, and whether the dependency name Dependabot reports for a subpath action is the repository (github/codeql-action) or the full path. The check takes the inclusive reading -- * matches across / -- which is the fail-closed direction, since it requires more pins to be bare majors and so stays correct if the narrower reading turns out to be Dependabot's. Under the narrower reading the ignore silently fails to apply and those pins draw within-major pull requests: drift in the noisy direction rather than the silent one. Determining it takes a run against the real service from outside this repository's firewalled development container -- a subpath action exact-pinned under a github/* ignore, and an observation of whether a bump opens. Observing this repository's own Dependabot pull requests does not determine it: its github/codeql-action pins are bare majors, so under either reading there may be nothing to open.

npm resolution residuals ​

Four records of what npm's resolver leaves behind in this workspace: the packages the release SBOM omits, what a root overrides block does to the lockfile it is added to or removed from and to later installs, the one override the root package.json holds, and the brace-expansion copy the development tree holds. Their normative residue is short -- the root package.json holds one overrides entry, for the project site's vite, and two CI checks watch what an overrides block leaves behind, the out-of-range edges and the split hoist. The measurement behind each is kept so the dead ends are not re-walked.

The release SBOM's hoisting residual ​

Release step 9 in RELEASES.md runs npm sbom with no workaround flag, and its BOM omits a few packages the shipped tree holds. Re-driven 2026-10-08 against the lockfile as committed (the figures depend on the npm version, 11.19.1):

  • npm sbom --sbom-format cyclonedx --package-lock-only --omit=dev -w packages/core -w apps/cli -w apps/web exits 0 at 131 components, and the same command without -w apps/web exits 0 at 85. Every one of those 85 also appears in the 131, by name and version, so the full-scope command closes the web console's runtime set into the BOM rather than dropping it.
  • npm ls --all --omit=dev --package-lock-only exits 0, so the tree reports no peer conflict.
  • yaml 2.9.1 and tslib 2.8.1 are installed in the shipped tree, per npm ls yaml tslib --omit=dev -w packages/core -w apps/cli -w apps/web, and absent from the step 9 command's component list. That list holds tslib 1.14.1, a copy nested under tsyringe.

The residual is a property of how npm sbom walks a hoisted entry shared with a dev-only consumer, and RELEASES.md states the query that checks one package's presence when the BOM lacks it.

What a root overrides block changes about later installs ​

The root package.json holds one overrides entry, recorded in The project site's vite override. Adding another is the answer of last resort here rather than a routine fix, for the three npm behaviors below: the block is not applied or removed by an ordinary install, it can hand a dependent a version its declared range excludes, and its mere presence changes how a later range bump is hoisted.

Adding or removing an override does not re-resolve the lockfile. npm leaves a lockfile whose existing resolution already satisfies its dependents byte-identical when an override is added, so adding the block appears to do nothing at all: re-driven 2026-10-01 on 11.17.0 and 11.19.1, npm install, npm install --package-lock-only, and each with --force return it unchanged. Removing one does the same. Driven 2026-10-01 by deleting a root "brace-expansion": "^5.0.8" override that held two minimatch copies declaring ^2.0.1 and ^2.0.2 on a hoisted 5.0.12: npm install --package-lock-only on 11.17.0 and 11.19.1, and npm install on 11.19.1, leave the lockfile byte-identical, and npm ls --all then marks both edges invalid. The route that applies either direction surgically is to delete the lockfile entries the change has to move and re-run npm install --package-lock-only: on that removal, deleting the hoisted node_modules/brace-expansion entry restored it at 5.0.12 and added exactly a [email protected] and a [email protected] entry under each of archiver-utils and readdir-glob, changing nothing else, on both releases. A from-scratch resolve applies an override too and is not the route: adding that same override that way on 2026-10-01 changed 246 package versions against the lockfile it started from, added 26 entries and removed 59, a far larger review surface than the change being landed.

An override can hand a dependent an API-incompatible major. An override forces every dependent onto the overridden version whatever range it declared, and npm does not reliably report the result. With such an override in force, npm 11.17 marks the edge invalid -- npm ls --all exits 1 with ELSPROBLEMS, and an unscoped npm sbom refuses with ESBOMPROBLEMS -- while npm 11.19.1 marks neither (measured 2026-09-27, re-driven 2026-10-01 on 11.17.0 and 11.19.1). What the edge is underneath shows only when the dependent's code runs. [email protected] exports a named expand and no callable default: Object.keys(require("brace-expansion")) is ["EXPANSION_MAX", "EXPANSION_MAX_LENGTH", "EXPANSION_MAX_DEPTH", "EXPANSION_MAX_REWRITES", "expand"], and .default is undefined. With the override above in force, braceExpand("a{b,c}d") threw from [email protected] with TypeError: expand is not a function and from [email protected] with TypeError: (0 , brace_expansion_1.default) is not a function, while npm run build -w apps/web exited 0, npm audit --package-lock-only answered found 0 vulnerabilities, and npm 11.19.1 listed both edges as deduped with no marking.

What catches that. scripts/check-locked-dep-ranges.mjs (npm run check:locked-dep-ranges, run by npm run check:all) reads the committed lockfile and fails on any edge whose locked version lies outside the range its dependent declared, naming the dependent, the declared range and the locked version, for every dependent the lockfile records: the root project, each workspace, and each installed package. An edge meant to stand is recorded with its reason in the check's OUT_OF_RANGE_BY_DESIGN list, whose only entries are the three React peer edges of @docsearch/react, the Algolia search package the project site's generator installs and the site does not build, and the vite edge of vitepress the root override forces; an override-forced edge fails until it is recorded, and a recorded edge that comes back into range fails until its entry is deleted. It compares declared ranges with locked versions only, and does not model npm's resolution or which override forced an edge.

A later range bump splits across the workspace boundary. This is the behavior an author who adds an override and moves on meets later. With a root overrides block present, npm does not always hoist a later range bump incrementally: the stale hoisted copy is kept and the new version is nested under the workspace that raised its range. What an operator or reviewer actually sees is not that mechanism but its result -- two copies of one package split across the workspace boundary, resolved by different dependents -- and nothing at install time reports the split.

The split depends on the command as well as the npm release. Re-driven 2026-10-01 by re-applying the vite bump below to the lockfile it started from and running npm install --package-lock-only, with the block present and with it removed:

  • npm 11.17.0 keeps node_modules/vite at 8.1.4 and nests the bumped range's 8.3.2 under apps/web with the block present, and hoists 8.3.2 to the root with it removed.
  • npm 11.18.0, 11.19.0 and 11.19.1 hoist 8.3.2 to the root either way.

Re-driven 2026-10-17 on npm 11.20.0, with the block present and not toggled. vite has no release after the 8.3.4 the lockfile holds, so playwright, which apps/web declares and the root also holds, stood in for it:

  • A named install into the workspace, npm install -w apps/web [email protected] over a root playwright 1.64.0, nests playwright and playwright-core 1.64.1 under apps/web, and scripts/check-nested-root-package.mjs fails naming both.
  • Deleting those two apps/web/node_modules lockfile entries and running npm install --package-lock-only hoists 1.64.1 to the root and the check passes.
  • Raising only the range in apps/web/package.json and running npm install --package-lock-only on the same bump hoists 1.64.1 to the root and nests nothing.

So a lockfile update can still produce the split: a named workspace install on npm 11.20.0, or a re-resolve on npm 11.17, run by hand or by a bump tool whose bundled npm is such a release. A named install was driven only on npm 11.20.0, not on 11.17 through 11.19.1, and only with the block present, so whether it splits without the block is not measured. No bump tool was driven here.

Measured on the apps/web bump of vite from ^8.1.4 to ^8.1.5: the regenerated lockfile kept node_modules/vite at 8.1.4 and nested apps/web/node_modules/vite at 8.1.5; vitest and the @tanstack/* chain resolved the root copy while @vitejs/plugin-react resolved the nested one. No package required vite below 8.1.5, so the retained root copy was a stale hoist and not a constraint. Toggling only the root overrides key against live npm, over two base lockfiles, is what attributes the behavior to the block's presence.

The remedy measured to work is to hoist the bumped copies to the root, drop the nested lockfile entries, and re-resolve with npm install --package-lock-only. Deleting the nested entries alone and re-resolving can resolve past the bumped version: on #2466, a group bump that nested the new vite, rolldown and rollup copies under apps/web, apps/cli and packages/core, that route moved rollup to 4.65.1, past the version the bump set. Hoisting the bumped copies to the root first and then re-resolving left the result unchanged, which held the bumped versions. Deleting alone did clear the playwright split above; what decides whether it overshoots was not measured, so the hoist is the route either way. Two routes were measured not to work: @dependabot rebase and @dependabot recreate each reproduce the duplicate. A from-scratch resolve does hoist correctly and is still not the remedy, for the reason stated above of the same route: it moves hundreds of unrelated package versions, which is a far larger review surface than the bump being landed.

What catches the next one. scripts/check-nested-root-package.mjs (npm run check:nested-root-package, a CI static check in static_checks.yaml) reads the committed lockfile and fails on any package installed at the top level of both the root node_modules and a workspace's, naming the package and both entries. The split then lands as a named failure on the bump's own pull request rather than as a downstream failure that does not name it. It covers every package rather than the ones this has happened to, because the mechanism is the block's presence and not any one dependency; a split that is meant to stand is recorded, with its reason, in the check's own NESTED_BY_DESIGN map, which is empty. What it reports is the split the lockfile records, not one npm would resolve, and only at that one depth: a copy nested under another package is ordinary conflict resolution, which this tree has dozens of.

The project site's vite override ​

The root package.json holds "overrides": { "vitepress": { "vite": "6.4.4" } }. VitePress 1.6.4, the newest non-prerelease, declares vite ^5.4.14, and every vite 5 release depends on an esbuild below 0.25, which GHSA-67mh-4wv8-2f99 covers (a website can send requests to esbuild's development server and read the response). Without the override, npm audit --package-lock-only reports esbuild 0.21.5, vite 5.4.21 and vitepress 1.6.4 with no fix available. vite 6.4.4 is the lowest release outside that advisory's range and outside five later advisories that cover vite 6.4.3 and earlier (GHSA-9jrq-w75r-8gcw, GHSA-rq7h-c2jc-7f22, GHSA-r825-w8mm-5pxw, GHSA-587h-7wmj-h5pm and GHSA-698v-4r3x-3vcv). It depends on esbuild ^0.25.0, which resolves to 0.25.12.

Measured 2026-10-26 on npm 11.20.0 with the override in force:

  • npm audit --package-lock-only answers found 0 vulnerabilities.
  • npm run site:build completes, with VitePress's dead-link check running.
  • The lockfile change is confined to the entries under node_modules/vitepress/node_modules, applied by the route above: delete those vite, esbuild and @esbuild/* entries, then npm install --package-lock-only.
  • [email protected] declares an install script, and the allowScripts map denies it as it denied 0.21.5; the site build above runs with that script blocked.
  • npm run site:dev serves the site on the overridden copy.

The edge is recorded in OUT_OF_RANGE_BY_DESIGN, and the nested form is one the allowScripts check reads. Remove the override when a non-prerelease VitePress declares a vite range outside the advisory.

The development tree's brace-expansion copy ​

The tree holds one brace-expansion copy, 5.0.12, hoisted at the root for [email protected] under eslint and development-only. On npm 11.19.1 against the committed lockfile: npm ls brace-expansion --all names that copy alone, npm ls brace-expansion --omit=dev prints (empty), npm's no-match answer, and npm audit --package-lock-only and npm audit --omit=dev --package-lock-only -w packages/core -w apps/cli -w apps/web each answer found 0 vulnerabilities. An advisory against it is a development-tree finding, triaged as CONTRIBUTING.md states, and an override answering one carries the costs recorded in What a root overrides block changes about later installs.

Upgrading the SFTP Stack (ssh2 / ssh2-sftp-client) ​

The channel-security bounds specified in CHANNEL_SECURITY.md, TRANSPORT_BOUNDS.md, TRANSPORT_LIVENESS.md, SFTP_CONNECTION.md and SFTP_SESSION_RECOVERY.md reach past the public ssh2-sftp-client API and drive ssh2 internals directly (apps/cli/src/connection/ssh2SftpAdapter.ts), so they rest on assumptions about ssh2's internal behavior that an upgrade can silently break. Re-verify the following on any ssh2 or ssh2-sftp-client version bump, before the bump merges.

Which of these assumptions a check holds, and which stay prose. An assumption earns a check when a run can assert it at the layer it is stated and the failure names the assumption that moved; otherwise it stays re-verify-on-upgrade prose, which at least fires at the one moment it matters. Importance does not decide it -- everything in this section is critical or it would not be here -- and neither does cost alone. What decides it is whether the assumption is separable from the adapter behavior built on it: a break that an adapter-level test can only report as an opaque timeout leaves a bumper reading this document anyway, so pinning it where it is stated is what the check buys. apps/cli/test/integration/sftpStackPremises.test.ts is where an assumption that separates that way is driven -- the raw ssh2-sftp-client, not the adapter, against the in-process test server -- and each assumption it holds is named where that assumption is stated below, the way the dial-deferral assumption already names its own check. Three classes stay prose:

  • An assumption about where in a dependency's source something happens rather than what it does -- an assignment's guard, a listener's bracketing, a constant's provenance. A run observes the behavior, not the shape of the code producing it; those are re-read from the source-file list below.
  • A shape the in-process backend cannot produce: everything that would need a native sshd, and the one late-answer shape recorded with the captured wrapper below.
  • An assumption whose only observable is an adapter behavior a suite already drives end to end, where a raw-layer check restates a covered outcome rather than separating a cause.

Which class an assumption falls in is decided at the assumption, not here: a bullet that names its check, or gives its reason for having none, has been through this; where a bullet does neither, the section's re-verify instruction is what stands.

The internal assumptions the adapter relies on:

  • ssh2's Client.sftp() strips its own setup-time 'error' listener (and the 'exit'/'close' ones) from the SFTPWrapper before handing it back, so after a real connect the wrapper has zero 'error' listeners until the adapter attaches its own. The whole crash fix is sound only while this holds; if ssh2 retains its listener the adapter's "no one else guards the wrapper" reasoning is false.
  • ssh2 reports end-of-directory from the handle-based readdir as an Error whose code equals STATUS_CODE.EOF (numeric 1), not as an empty success batch -- the EOF contract list()'s batch loop terminates on. Each call answers one server batch, and a call that errs passes no list; all three are driven in apps/cli/test/integration/sftpStackPremises.test.ts.
  • STATUS_CODE.EOF === 1 and OPEN_MODE.WRITE | CREAT | EXCL === 0x2A, the numeric SFTP constants the adapter hard-codes (it does not import them: ssh2 exposes their runtime values only from the internal lib/protocol/SFTP.js, not from its package entry point, and @types/ssh2 types them only as a compile-time sftp namespace). These are fixed SFTPv3 wire-protocol values, so they are extremely unlikely to renumber, but confirm them against STATUS_CODE/OPEN_MODE in the source below if the surrounding code moves.
  • STATUS_CODE.FAILURE === 4 (SSH_FX_FAILURE), the third numeric SFTP constant the adapter hard-codes: rename() retries a transient server failure only on this status (the source still exists, so a re-issue is safe) and treats every other status as terminal, and createExclusive maps it to an exists()-disambiguated EEXIST. The assumption is that ssh2-sftp-client reports a server FAILURE on its high-level rename as numeric err.code === 4 -- it passes ssh2's raw status through fmtError onto err.code, the same 4 createExclusive reads from the raw open callback. If a future version remaps rename's err.code to a non-numeric string (e.g. ERR_GENERIC_CLIENT) or renumbers FAILURE, the retry silently stops firing -- the transient-rename flake returns, with no correctness break; the ssh2SftpAdapter unit tests pin the numeric-4 behavior.
  • ssh2-sftp-client stores the raw wrapper on this.sftp, assigned in its 'ready' handler on each connect() and otherwise only cleared -- the library performs no auto-reconnect of its own. Two adapter behaviors rest on this. (1) The captured-wrapper reads in list()/createExclusive() (const { sftp } = ... at method entry) assume nothing swaps this.sftp under an in-flight operation, and issuance order is not what holds that up -- the adapter issues operations concurrently (see SFTP_SESSION_RECOVERY.md). The library's assignment discipline is: this.sftp is assigned only from a connect() whose "already connected" guard passed, so every assignment is preceded by a clear, and only events implying the old transport has ended clear it. Driven against a real ssh2 server, no run reassigned the property while a list()/createExclusive() capture was live, identically with ephemeralSessions true and false. On the mid-exchange recovery path against a partner withholding its connection close, the forced close fails the captured wrapper's outstanding requests 210-221 ms before the fresh wrapper is assigned, across six samples, all the same sign, and the re-issue then captures the fresh wrapper. A capture that did go stale would be inert rather than misrouted: a request issued on a superseded SFTPWrapper neither transmits nor calls back (staleWrapper.realpath(".", cb) produces no callback at 1500 ms, while the fresh wrapper answers the identical call in single-digit milliseconds). And the fresh session is a different ssh2 channel on a different socket, so it cannot answer that request either. The holding operation would ride its own per-operation deadline rather than read another session's reply. That inertness is the security-bearing half of this bullet -- it is what makes a stale capture a deadline rather than a cross-session misroute -- so it is a check rather than prose: apps/cli/test/integration/sftpStackPremises.test.ts captures a wrapper, drops the session server-side, re-dials, and holds the superseded wrapper silent while the fresh one answers. The assignment discipline itself stays prose and is re-read from the source on a bump -- it is a claim about which guard an assignment sits behind, not about an observable outcome, and a version that reassigned the property under a live transport would put a stale capture under a live operation with nothing to see until it did. One shape is left unmeasured by design, rather than pending: listOnce's late-opendir branch issues sftp.close(openedHandle) on the capture after the listing has settled, the one place a superseded wrapper could still be issued against, and reaching it needs a backend that answers a request late where this one can only withhold a response forever. What that branch stands to lose is bounded by the inertness above -- the listing has already settled, so the close is best-effort cleanup and its worst case is a handle held until session teardown -- which is why the boundary is not built for it. Nor was any of this driven against a native sshd. (2) Recovery re-dials by calling connect() again and does not call the library's end() first. That rests on ssh2-sftp-client clearing this.sftp on a clean drop, on the forced close below clearing it where a drop did not (so connect()'s "already connected" guard cannot fire either way), and on end() latching a permanent endCalled flag that disables the global 'close' listener's this.sftp clear. That is why calling it would break detection of a later idle drop. If a future version stopped clearing this.sftp on a clean close/end, or reset endCalled on connect(), re-verify the recovery re-dial path.
  • The connection-per-poll (ephemeral-session) mode's idle-boundary release (releaseForIdle, gated on the internal ephemeralSessions option; see SFTP_SESSION_RECOVERY.md) is a new caller that closes the session, leaving the adapter in the same cleared-session state a server drop produces; what tells a deliberate release apart from a drop, and what each records, counts and warns, is specified in SFTP_SESSION_RECOVERY.md. It drives the underlying ssh2 Client's own end() (reached via this.client, the same boundary setNoDelay uses -- not ssh2-sftp-client's end(), which would latch endCalled and permanently disable the global 'close' listener the mode depends on) and then awaits the Client's 'close' event to know the teardown finished before the poll loop idles. The critical assumption is that Client.end() emits 'close', not merely 'end': releaseForIdle awaits exactly 'close' (via Client.once('close', ...), bounded by a local CLIENT_CLOSE_TIMEOUT_MS safety check so a withheld close cannot hang the loop). It is that 'close' -- not 'end' -- that fires ssh2-sftp-client's constructor globalListener to clear this.sftp, leaving the adapter in the exact cleared-session state a server drop produces, ready for the next cycle's ensureConnected to re-dial. A session still set after that wait is reachable on the pinned version with no dependency change at all: Client.end() is _sock.end() and the Client emits 'close' only from the socket's own 'close'. A partner that accepts the disconnect and never closes the connection leaves it in half-close, and the local safety check settles the release first (measured against a real ssh2 server: the release spends its full bound, this.sftp is still set, _sock.writableEnded is true and readableEnded false). That state is not a surviving session -- the transport is already ended, so it can hold nothing and an operation issued on it does not settle -- and the release does not hand it to the next cycle: it forces the socket closed (next bullet). The boundary is not assumed: in this mode connect() verifies that the Client still exposes end, once, and removeListener, and fails the dial with one error naming the boundary and this document if any of them has moved.
  • The forced close reaches one further internal: net.Socket's own destroy() on the socket the bullet above names (this.client._sock.destroy), which connect() verifies in this mode alongside the boundaries above. On the branch where the release's own end() ended the transport but no 'close' arrived within the bound, the release destroys that socket and awaits the Client's 'close' under a second, shorter bound (FORCED_CLOSE_TIMEOUT_MS), then checks that this.sftp actually cleared. That is the one assumption here connect() cannot verify, because nothing at connect time destroys the socket, so it is read back at that point and raised there instead. Measured against a real ssh2 server: the clear is not synchronous with destroy() (it still reads set in the same tick), the Client's 'close' follows in single-digit milliseconds, this.sftp is then cleared, the next ensureConnected dials, and the re-dial is charged to neither reconnect counter. The alternative shape -- leave the socket alone and clear the session property instead -- was measured and rejected: connect() refuses outright while the property is set (connect: An existing SFTP connection is already defined), and the abandoned half-open socket stays alive. So when the partner eventually does close it, the global 'close' listener clears the fresh session's property and the next operation fails as an unexpected end -- a spurious counted drop, plus a leaked descriptor per cycle. The forced close's bound is the one liveness timer in this adapter left ref'd: the socket it waits on has just been destroyed, so nothing else holds the loop open and an unref'd timer would let the process exit (code 0, mid-exchange) instead of reaching that check -- measured both ways. Re-verify on an ssh2 bump that _sock is still a net.Socket exposing destroy() and that destroying it still drives the Client's 'close'.
  • The connection's terminal close (SSH2SFTPClientAdapter.end) reaches one of the same boundaries in both session modes -- nothing on that path is gated on ephemeralSessions. The control itself, its two bounds, and what each degraded branch warns are specified in SFTP_SESSION_RECOVERY.md; four library assumptions support it, each measured against a real ssh2 server. (1) ssh2-sftp-client's end() drives the ssh2 Client's end() (a socket half-close) and resolves only from its own 'close' handler, so a partner that accepts the disconnect and never closes the connection leaves that promise pending indefinitely. Measured with the close withheld, it was still pending at 20 s with ephemeralSessions false and at 20 s with it true, against 3 ms with a server that closes. (2) Destroying the socket beneath a pending end() resolves that promise rather than rejecting it (measured: resolved 2 ms after destroy(), with _sock.destroyed already true synchronously in the same tick), which is what makes the forced path a settlement of the call it raced rather than an abandonment of it. (3) An end() that rejects closed nothing: ssh2-sftp-client raises that rejection from the temporary 'error' listener it installs for the call, whose endListener/closeListener are gated off by endCalled, so the socket is left exactly as a withheld close leaves it -- which is why a rejection is treated as the same open connection and not as a completed close. (4) An abandoned half-open socket is a ref'd libuv handle, and the CLI's success path exits by event-loop drain, so bounding the wait without destroying returns the caller over a process that never exits (measured in a child process against a withholding server: close() had neither returned nor let the process exit 60 s later). Re-verify all four on a bump, along with _sock.destroyed as the read-back -- the one assumption connect() cannot check, because nothing at connect time destroys the socket, so it is read at the boundary instead.
  • The default held-session mode verifies the destroy boundary at dial time as the connection-per-poll mode does: connect() resolves client._sock.destroy, with Client.once/removeListener, in both session modes, because the subsystem-open bound closes an abandoned dial through them (see the bullets below), and refuses the dial where one has moved. What the default mode is not held to at dial time is the rest of the idle release's set -- end and _sock.writableEnded -- which it never drives. The terminal close still resolves _sock.destroy again where it uses it and, where it has moved since the dial, warns (naming the boundary and this checklist) and returns bounded rather than throwing. The close requires only what it drives -- _sock.destroy, and not the end/once/removeListener/writableEnded the idle release needs -- so a bump that relocated one of the wider set cannot disable a forced destroy that would still have worked. Consequence for an upgrade: in either mode a relocated _sock.destroy, Client.once, or Client.removeListener fails the dial with the library-incompatibility refusal rather than surfacing later as a teardown warning.
  • The mid-exchange session recovery widens that reach further, in both session modes and beyond the destroy: a partner that drops the SFTP session and then withholds its connection close leaves the same half-open transport the release's bullets describe, with no release involved. Measured against a real ssh2 server made to drop an active session with its close withheld: the operation on the wire is never answered and ends on the adapter's own per-operation deadline, the socket beneath the Client reads writableEnded: true, readableEnded: false, destroyed: false, and this.sftp is still set -- identical with ephemeralSessions true and false. The control: the same drop against a partner that does close clears the property and recovers on one re-dial, in 228 ms. What the adapter does with that reading is specified in SFTP_SESSION_RECOVERY.md; what it reaches here is the release's forced close -- Client.once/removeListener for the 'close', _sock.destroy, and Node's _sock.writableEnded/readableEnded as the ended-transport reading -- ahead of the re-dial, so the dial runs over a destroyed socket and ssh2-sftp-client's "already connected" guard cannot fire. Those boundaries are resolved at use, in both modes, for the reason the previous bullet gives: the severity here is a warning plus the operation's own terminal failure, never a failed dial, so a bump that relocated one costs an exchange nothing it had not already lost. Client.once/removeListener and _sock.destroy are also verified at dial time in both modes, for the subsystem-open bound's sake (next bullet), so a relocation of one of those three is refused there instead. Re-verify on a bump that a server-side session drop with the close withheld still leaves this.sftp set over an ended _sock, and that destroying that socket still drives the Client's 'close' and the property clear; apps/cli/test/integration/heldSessionWithheldClose.test.ts drives it end to end and dialDeferral.test.ts censuses the socket the re-dial is issued over. Only the in-process backend can be made to withhold a close, so none of this is verified against a native sshd.
  • That recovery reaches one assumption further, into the order in which the pinned stack delivers a drop, because ssh2-sftp-client's per-operation listeners settle the operation -- and clear this.sftp -- from the ssh2 Client's 'end' as well as its 'close'. Measured against a real ssh2 server made to drop an active session cleanly under an in-flight operation, at each of the first three opcodes of the operation. Every high-level call rejects between the Client's 'end' and its 'close' -- get, put, delete, rename, exists and the library's own list, each with an Unexpected end event message -- with this.sftp already cleared at that 'end'. The raw readdir the adapter's list/createExclusive drive instead rejects with No response from server after the 'close', cleared by it. So the side of the 'close' a tear lands on follows the route, not the operation's name, and a route can settle later than its own listener fires -- the adapter's chunked put settles through its bounded source, a hop past the library's rejection. The socket does not separate the two -- at the high-level rejection it reads writable: false, writableEnded: true, readableEnded: true, destroyed: true, which is what it reads once the whole sequence has run as well. So the still-owed reading is taken from the Client's own 'end'/'close' pair, one persistent listener pair per adapter on the same Client.on boundary the keyboard-interactive answer handler uses. It is needed because a dial issued while that 'close' is owed is failed by ssh2-sftp-client's connect-time listeners (getConnection: Unexpected end event) while the handshake it started runs on unowned at the server, so the recovery drives the previous bullet's forced close whether or not the session property is still set, and waits the 'close' out before dialing. A Client exposing no on() leaves no reading to take, and the flag's unwritten default is the same value a delivered sequence leaves, so the retirement separates the two by whether the watch attached and treats an unwatched Client as cannot tell rather than as nothing owed. It warns once per adapter, naming the boundary and this checklist, and takes the conservative branch: the forced close, then the dial. Driven against a mocked Client in apps/cli/test/unit/connection/ssh2SftpAdapter.test.ts on the two shapes the relocation can take. With on() gone and once() still in place, a get/put/exists torn in flight still recovers on one dial (this.sftp cleared, _sock.destroy driving the 'close'), so the boundary costs the reading and a wait, not the recovery. The wait is that on a transport whose 'close' had already landed, the forced close spends the full FORCED_CLOSE_TIMEOUT_MS, since a 'close' listener armed after the event never resolves early. With the whole EventEmitter surface gone -- Node's once() is itself implemented in terms of on(), so a Client that has actually lost the surface loses both -- the forced close has no boundary either, and the dial is refused under a second warning. That leaves the operation the ERR_NOT_CONNECTED it already had rather than a connect error and a spent budget: the same severity choice the previous bullet makes. Neither shape is reached on the pinned versions: new (require('ssh2').Client)() reads instanceof EventEmitter true on [email protected], with on, once, and removeListener all functions. Re-verify on a bump that a clean server-side drop under a high-level operation still settles it from the Client's 'end', ahead of the 'close' and with this.sftp already cleared, that destroying the socket still drives that 'close', and that the Client still exposes the on() the reading is taken through. apps/cli/test/integration/inflightDropRecovery.test.ts drives the operation classes and a sweep of cut positions inside one transfer. inflightDropExchange.test.ts holds a two-party exchange cut mid-message to a two-sided contract -- delivering the message or rejecting the sender's send(), and where the cut tears the reply to a write the partner then consumes, rejecting it as an undetermined outcome rather than as a determined non-delivery. concurrentOpDropRecovery.test.ts holds a cut that tears several operations at once to settling every one of them on the tear, and dialDeferral.test.ts censuses the socket the re-dial is issued over. Only the in-process backend can be made to cut a session this way, so none of this is verified against a native sshd, and the measured shapes are ssh2.Connection.end() at a counted opcode and the same end() from inside the RENAME handler, on either side of that request's fs.rename -- not the whole class of clean drops.
  • ssh2's Client.connect() issued on a socket that is still writable defers the attempt behind once('close', ...) and arms no readyTimeout for the deferred attempt, so Alcove's serverConnectTimeoutMs-derived bound silently does not apply to it. Measured on the pinned versions, a second connect() on a live socket (writable === true, readyTimeout: 5000) was still unsettled at 45016 ms with no 'ready' and no 'error', and the same at the ssh2-sftp-client layer (44998 ms) with its session property force-cleared. The precondition is narrower than "an open connection": writable === true -- a transport not yet ended -- together with a cleared session. Neither an ended transport (writableEnded: true, destroyed: false) nor a destroyed one defers; a dial on either settles in dial-scale time (~220 ms measured). No adapter path reaches the deferring state, and that is a check rather than a note: apps/cli/test/integration/dialDeferral.test.ts drives each dial path against the real stack -- against a partner that withholds its close, and against one that cuts cleanly under an in-flight high-level operation. It records the state of the socket beneath every dial the adapter issues, and fails if any is writable, if any dial does not settle, or if the live-session gate stops firing. The library mechanism itself still exists, so the unreachability is a property of Alcove's dial gates (ensureConnected's if (sftp) return true, and shouldRecoverFromSessionLoss, which refuses a set session unless its transport has ended) plus the three forced closes above, which leave an ended transport destroyed rather than writable. The recovery's own forced close runs before its own re-dial, so that dial too is issued over a destroyed socket. That is exactly what the check pins. See connection-per-poll-sftp.md for the driven measurement behind it, the two barriers, and the caveats on its scope.
  • ssh2's readyTimeout covers a dial only as far as authentication: ssh2 clears it in its USERAUTH_SUCCESS handling, and the subsystem sftp request ssh2-sftp-client issues next has no deadline of its own, in either package. So the phase after authentication is bounded by Alcove, not by the library: the adapter subscribes to the ssh2 Client's 'ready' -- the same event ssh2-sftp-client waits for before requesting the subsystem, and the one it exposes as this.client from its constructor, so the subscription is in place before the dial -- and arms the same serverConnectTimeoutMs window over what follows (see TRANSPORT_LIVENESS.md). Both halves are checks rather than prose, in apps/cli/test/integration/sftpStackPremises.test.ts: that 'ready' precedes the subsystem request, and that a dial whose subsystem reply is withheld is still unsettled several times past its own readyTimeout, its socket undestroyed and writable, and holding its process open with nothing else on the event loop, which is why the bound's own timer is unref'd. An expiry closes the socket beneath the abandoned dial and waits the Client's 'close' for it out before reporting, on the same bound the two forced closes above take: measured against the pinned stack, a dial issued while that 'close' is still owed is failed by ssh2-sftp-client's connect-time listeners (getConnection: Unexpected close event) without ever reaching the server, four such dials back to back leave the client dialing nowhere at all, and the wait itself costs 0-6 ms where the 'close' lands. apps/cli/test/integration/subsystemOpenBound.test.ts holds both shapes -- a re-dial chained onto a bounded failure bounded again at the server, and four back-to-back failures leaving the connection able to dial a server that answers. A version that grew a deadline for this phase makes Alcove's redundant rather than wrong; one that requested the subsystem before 'ready' would arm it over a phase that had not begun, and fails that check. The bound is not optional: a dial on a Client that no longer exposes once or removeListener is refused before it dials, and a first dial that succeeds on one whose socket no longer exposes destroy is refused there, both as a usage error (CLI exit 64), because without them the bound cannot be armed or a later dial would defer behind an abandoned one with no deadline. A first dial that expires before the socket could be checked leaves the expiry nothing to close it with, so it warns that the connection is left to the operating system and fails with its own expiry.
  • A failed dial of any kind can reject before its connection has closed. Measured against the pinned stack, a dial whose key exchange the server refuses rejects before the Client's 'close' for its connection, and a dial issued on the same Client before that 'close' lands fails as well, against a server that accepts it. The socket cannot tell whether that 'close' is still to come: Node sets a socket's closed and destroyed flags synchronously inside destroy(), before its 'close' event fires, and a dial that fails on its handshake timeout or on a reset connection rejects over a socket already in that state. So the adapter subscribes once to the Client's 'close' before each dial, removes that listener however the dial settles, and takes that event as the only reading of whether the dial's 'close' has been delivered. After a failed dial whose 'close' it has not seen, it waits for that 'close', destroys the socket only if it does not come within the same bound as the closes above, and waits the destroy's 'close' out on that bound too, all before it reports the failure. A socket exposing no destroy() at that point is left to the operating system under a warning, once the wait for its own 'close' has run. A socket the dial did not open, such as the live session's that a refused connect() leaves in place, is left alone. Each assumption is a check in apps/cli/test/integration/sftpStackPremises.test.ts: a dial issued before that 'close' fails, a destroyed socket reads closed before the Client's 'close' fires, a dial that fails on its handshake timeout or on a reset connection rejects over a socket already closed with that 'close' still to come, and a dial issued after that 'close' connects. apps/cli/test/integration/sftpKexRedialPolicyFlip.test.ts drives the adapter's cycle-start re-dial at once after a refused one and requires it to connect. Two readings stay open: a 'close' arriving late from an earlier connection, after that connection's own bounds have run out, is counted as the failed dial's own, so the new socket is left open and the next dial on it runs on its own deadline; and a destroyed socket whose 'close' does not arrive within the bound is reported without a warning, as the release's forced close is.
  • That release also reads Node's own half-close flags on the socket the bullets above reach (this.client._sock.readableEnded / .writableEnded) to tell who ended the transport, because the session property alone does not say. readableEnded marks a teardown the peer began: ssh2 emits 'end' on the peer's FIN and 'close' only after it, and ssh2-sftp-client's global 'end' listener leaves this.sftp set, so without that flag a release landing in the window between the two would drive a no-op end(), see the 'close' promptly, and read a server-side drop as a release of its own. writableEnded is read at two points and answers a different question at each. Before the release drives its own end(), a writableEnded transport is one something other than this release ended -- ssh2 answering a partner's SSH_MSG_DISCONNECT is the shape it is read for, alongside the withheld-close drop the recovery bullet above measures. After that end(), the same flag is the transport-is-ended assumption above, kept as a runtime check rather than a claim: true is the partner withholding its close, over a transport this side had already ended, so the release draws no 'close', spends its bound and forces the socket shut. false is an end() that did not end the socket, so the session may still be live. What each of those readings then records, counts and warns is specified in SFTP_SESSION_RECOVERY.md. Measured against the in-process server driven to drop a session with its close withheld and the wire empty: the release forces the close and emits its operator line once, reconnects and the mid-exchange sub-count both stay 0, and the next operation's pre-establish gate dials exactly once -- apps/cli/test/integration/ephemeralSessionExchange.test.ts drives it. Both flags are plain net.Socket properties rather than ssh2 internals, so only the _sock boundary itself is at risk on a bump, and connect() verifies in this mode that _sock reports writableEnded -- a socket that does not is a failed dial rather than a reading that silently stops meaning anything.
  • The adapter's session-transition serialization (end() takes the same FIFO lock every dial and release takes; see SFTP_SESSION_RECOVERY.md) can put two end() callers on the adapter at once, and its memoized terminal close is what holds them to one client end(). ssh2's own idempotence sits behind that memo as a safety check rather than as the guarantee: it guards Client.end()'s body with isWritable(this._sock), so a second call is a no-op that raises nothing, and ssh2-sftp-client's end() is likewise idempotent via its endCalled latch. Confirm both on a bump -- a version that dropped the isWritable guard or made a second Client.end() throw is a behavior change to note -- though the re-entrant teardown does not rest on either.
  • That serialization's acquire is bounded (TRANSITION_ACQUIRE_TIMEOUT_MS, 10 s), and the one waiter whose expiry still closes something is teardown: it gives up ssh2-sftp-client's end() and destroys this.client._sock while the transition it was waiting on -- typically a dial mid-handshake -- still holds the client. Three assumptions about the pinned stack support that branch, each a check in apps/cli/test/integration/sftpStackPremises.test.ts driven at the ssh2-sftp-client layer -- not through the adapter, where every one of them displays as the same opaque timeout. They run against a real ssh2 server made to answer the SSH identification string and then write nothing further, so the client's dial hangs established but never ready. First, ssh2-sftp-client's end() driven mid-handshake closes nothing: it short-circuits on the session the handshake has not restored and resolved in 1 ms with the socket untouched (destroyed: false, writable: true) and the dial still pending. That is why this branch reaches the destroy and never end(), and why it cannot reuse the terminal close's forced branch (that one waits out the end() it captured; this teardown has none and must not create one). "Must not" is measured rather than preferred, and has a case of its own in that file: an end() driven mid-handshake ahead of the destroy also disables it, leaving the parked connect() unsettled 3 s later where the destroy on its own settles it inside that same 1-5 ms. Second, destroying the socket beneath a mid-handshake attempt settles that attempt rather than leaving it pending: _sock.destroyed reads true synchronously on return from destroy(). The parked connect() rejects 1-5 ms later across five samples, and with no retry budget left the TCPSocketWrap handle is gone one tick after and a child process that does nothing further exits code 0 about 50 ms later. Third, that rejection is a plain Error with code: "ERR_GENERIC_CLIENT" and message getConnection: Unexpected close event, and its machine-readable half is the one a peer close gives: the same class and the same code the server's own close of the same stalled connection produces, so nothing branching on class or code can attribute this destroy to the partner. The messages differ, and only in the transport event each names -- the peer's close arrives as getConnection: Unexpected end event -- so the message reads which event the transport delivered and not who caused it, and a matcher on either fragment reads a cause that is not in it. Both are pinned. The consequence that is Alcove's own code rather than the library's: with retry budget left, connect()'s loop re-dials on a fresh socket about a second after the destroy and the process stays alive for the remainder of the dial budget (measured), so that loop reads the teardown latch between attempts. A version that left the attempt pending after the destroy, that resolved end() by actually closing the transport mid-handshake, or that gave the destroy path an error code of its own changes what an expired teardown wait does, and fails that file rather than needing a hand-run re-verification. The bound itself rests on none of them -- it is a fixed ceiling on the wait, not on the transition being waited on -- so it does not inherit the per-attempt readyTimeout assumption a dial-side ceiling would have. See connection-per-poll-sftp.md for the driven measurement behind the figures above and its limits. The checks inherit one of them: the stall is imposed as the connection is accepted, which is after ssh2 has answered the identification string, so a server that stalls once the key exchange is under way is a different and unmeasured shape.
  • ssh2-sftp-client exposes the underlying ssh2 Client as this.client, and ssh2's Client.setNoDelay(true) toggles TCP_NODELAY on the live socket. connect() calls it once after the connection is established to disable Nagle's algorithm -- a per-round-trip latency optimization for the chatty rendezvous protocol. Unlike every other assumption here this one has no correctness weight: the call is guarded and non-fatal, so a future version that relocates the Client or drops setNoDelay makes the adapter log a warning and continue with Nagle enabled (slower, still correct) rather than fail.
  • The ssh2 Client exposes its underlying net.Socket on this.client._sock, and ssh2 exposes setNoDelay but not setKeepAlive, so connect() reaches the socket directly to enable kernel TCP keepalive (_sock.setKeepAlive(true, SFTP_TCP_KEEPALIVE_DELAY_MS)) -- the transport-layer safety check beneath the application heartbeat (see TRANSPORT_LIVENESS.md). Like setNoDelay this has no correctness weight: the call is guarded and non-fatal, so a version that renames or relocates _sock, or one where _sock is not a net.Socket exposing setKeepAlive, makes the adapter log a warning and continue without kernel keepalive (the application heartbeat still defeats the server idle timeout) rather than fail.
  • The application heartbeat issues its no-op keepalive through ssh2-sftp-client's public realPath("."), so it adds no internal-API coupling of its own, and the ssh2-sftp-client concurrency assumption governing it is much narrower than "two operations must not be in flight on one client at once". Overlap on a healthy session is safe: driven against a real ssh2 server, 16 distinct pairs issued in the same event-loop turn, 5 rounds each (80 pair executions), covering get against get/put/delete/rename/exists, list against get/list/createExclusive, createExclusive+exists, put+delete, rename+exists, safeDelete+get, and the heartbeat's own realPath(".") beat against get, list, createExclusive and another beat. Every operation returned the correct result for the server state at the time -- including the later rounds' application-level answers to state an earlier round had changed, a code: 2 on re-deleting and an EEXIST on re-creating an existing lock -- with no hang, no cross-talk and no mis-routed error. A non-fatal failure of one member does not disturb the other either (6 arms: a failing get (ENOENT), createExclusive (EEXIST) or delete (ENOENT) alongside a list parked on the wire, each of the three driven once on a healthy session and once with a drop following the failure; each failure got its own correct error and the survivor was unaffected). That holds because removeTempListeners removes by identity, so the survivor's own listeners stay installed. What overlapping operations do cross is the session-loss rejection: addTempListeners prepends its per-operation listeners, so the most recently issued high-level operation's listener runs first and consumes the ssh2 Client's 'end', and the earlier operation's listener is then suppressed by the library's eventHandled bookkeeping and never rejects it. Issuance order therefore decides which of two concurrent operations takes which path out of a session loss, and the two paths do not have the same outcome. Measured on a clean partner-side drop, reversing the order within a pair reversed which member the rejection landed on -- between exists and get, where it reversed which of the two failed, and between the beat and a get alike. The beat is not special with respect to that crossing (it behaves as any other high-level operation, its being a beat mattering only in that its own outcome is swallowed). The adapter issues concurrent operations of its own with no suppression of any kind, so the beat suppression covers the beat and nothing else -- what it is still worth is in TRANSPORT_LIVENESS.md. Re-verify on a bump that addTempListeners still prepends and its _resetEventFlags/eventHandled bookkeeping still suppresses the earlier listener once a later one has consumed the event (the ordering the crossing rests on), and that removeTempListeners still removes by identity (what leaves a survivor its own listeners). A version that serialized operations internally, or gave each operation its own connection-level listeners, retires the crossing rather than changing it.
  • Whether the members that do not take the rejection settle at all is a separate question from which one takes it, and is measured separately, because the recovery re-dial that the taker enters is what the others are outstanding across. Driven against a real ssh2 server made to cut a session cleanly with several operations on the wire: a raw-wrapper list (batched one name per READDIR, so it spans hundreds of opcodes) alongside a high-level get of 8 MiB, in both issue orders and at five cut positions inside the pair, 50 runs. Also driven: forty-way Promise.allSettled delete and Promise.all safeDelete fan-outs, 18 runs each. Every member settled on the tear rather than on its own per-operation deadline, and every one inside the recovery chokepoint was re-issued and returned its own correct result -- the pair in 278-495 ms and the delete fan-out's forty members in 224-260 ms, against a 3000 ms deadline set for the runs. The tear order follows the operation kind and not issue order: the high-level get is settled from the Client's 'end' and enters recovery first in both orders, and the raw list from the 'close' the retirement waits out, 0.13-0.47 ms after it. The beat as the outstanding member was driven under both partner classes -- parked on the wire by a withheld REALPATH response at a clean cut, and issued into the transport a withheld connection close leaves half-open (outstanding 2.0-2.8 s, settled 1.4-2.5 ms after the forced close's 'close', never at its own 3000 ms deadline). It rejected in both cases with realPath: Unexpected close event, never a stall. A beat is parked on the wire through the in-process server's REALPATH withhold, the same inject.withholdOn hook its other served opcodes use. apps/cli/test/integration/concurrentOpDropRecovery.test.ts holds each of these shapes against that server -- the pair in both issue orders, both fan-outs, and the beat under both partner classes -- at one cut position and one execution apiece. The cut-position sweeps and repeats behind the counts and ranges above were driven ad hoc and are not in the tree, so a bump re-drives the shapes rather than reproducing those samples. Re-verify on a bump that a member not taking the loss rejection still settles from the transport's own lifecycle rather than being left outstanding, since that is what keeps the stall exclusion above from swallowing a genuine loss. The runs' limits: nothing was driven in any state for the rename() re-issue's existence probe against anything; and at a session death specifically, nothing was driven for get against put/delete/rename, for list+list, put+delete or rename+exists, or for keepalive against createExclusive or another keepalive. Core's own concurrency -- a send() running alongside the free-running poll loop -- put two operations on the wire at the tear in 12 of the 20 runs driven across ten cut points, and none of those runs reached the window above at all (every message was delivered and every send() resolved). So the shapes above measure the adapter under such concurrency rather than showing the poll loop producing it at a drop. Nothing was driven against a native sshd, and the withheld-close class cannot be.
  • What the same per-operation listeners cost is measured on the same assumption, because concurrency on one Client is what makes them stack. addTempListeners attaches exactly one 'end', one 'close' and one 'error' listener per in-flight operation, and removeTempListeners takes all three off when it settles. So a fan of width w peaks at w + 2 on 'end' and 'close' and w + 1 on 'error' above the persistent 2/2/1 a connected client holds (ssh2-sftp-client's three constructor globalListeners, plus the adapter's own 'end'/'close' transport-lifecycle watch). Node's default ceiling is 10 (events.defaultMaxListeners, which client.getMaxListeners() reads back), so a concurrent fan of 8 is silent, 9 emits two MaxListenersExceededWarnings ('end', 'close') and 10 or more emits three -- identical in the default held-session mode and under connection_per_poll. Node raises that warning through process.emitWarning and never through console, so in a real child process it lands on stderr only (0 occurrences on stdout, where the CLI's own [INFO] operator lines go) and neither the integration suite's console sentinel nor withCapturedLogs can observe it; an in-process process.on('warning', ...) can, and is the hook the checks use. Its verbatim first line is (node:NNNNN) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 end listeners added to [Client]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit.
  • The stacking is bounded rather than a leak: every shape driven came back to the exact persistent baseline on every event name once its fan settled, including a 512-wide fan whose peak was 1,541 concurrent listeners. So the adapter raises the ceiling on that one Client at construction to SHARED_SSH2_CLIENT_MAX_EVENT_LISTENERS, derived in code from the bounds that admit a fan onto the emitter rather than picked, and summed rather than maxed so that nothing rests on two fans being unable to overlap. That sum is 2 * MAX_DIRECTORY_ENTRIES (16384) for core's rendezvous entry sweep, plus MAX_DEFERRED_CLEANUP_DELETES (64) for the connection_per_poll cleanup drain's concurrent re-issue, plus 3 for what runs beside a fan, plus the persistent 2 -- which is 16453. The entry sweep's term is twice the listing bound because that sweep is scoped to a party's directories rather than to one listing: sweepProtocolFiles merges the inbound listing's peer hellos and unexpected protocol files with the outbound listing's own leftovers into one array and fans a delete per element of it in a single Promise.allSettled. Under a split inbound/outbound scope those are two listings, each refused separately at MAX_DIRECTORY_ENTRIES, so one fan holds their union; under a shared scope the two collapse to one listing and the term is slack. That same term covers the three narrower delete fans core's entry scan (scanEntryDirectory) issues ahead of the sweep -- the orphaned-temp sweep and the leftover-abort-marker sweep over the inbound listing, and the split-mode outbound-orphan sweep over the outbound one -- each a Promise.all of safeDelete over a subset of one guarded listing and so at most MAX_DIRECTORY_ENTRIES, half the term. Each is awaited to completion before the next is issued and before sweepProtocolFiles, so none of them stacks on another or on the sweep, and the widest fan the entry scan puts on the client at any instant is the sweep's own. The drain's term is added to it rather than maxed against it because the entry sweep's deletes reach the same recovery chokepoint that sets the drain running. The ceiling is seated at that peak rather than one above it, because Node warns only strictly above a ceiling: an emitter whose maximum is n is silent at n listeners and warns on the n + 1th. That is driven both at a toy value (max 3: silent at 3, warns at 4) and at the adapter's own (max 16453: silent at 16453, warns at 16454), of which only the second is a committed check. Every enumerated fan, all of them at their bounds at once, is therefore silent, and the first listener past the enumeration is what warns. The 3 is measured rather than summed: a best-effort safety check held to evidence rather than an exhaustive count of what core can put on one connection, so the concurrency it is meant to cover is not a list of addends that totals it. An ordinary two-party exchange and its connection_per_poll variant are driven with the listener probe on each party's client, and the widest headroom either party spends above its idle baseline is asserted to stay within it. Both shapes spend 2 of the 3, on 'close' and at teardown -- the pair ssh2-sftp-client's end() parks while it waits the close out, its own close handler plus the per-operation 'close' bracket the end() call itself takes. What the term is meant to cover is that teardown pair together with the heartbeat's realPath(".") beat, the poll loop's own operation, and a send() resuming from the protocol continuation; which of those can be in flight together is core's business rather than the adapter's, which is why the term rests on the driven shapes instead of on that list. Crossing the ceiling costs a spurious warning and nothing else. That is what lets the one fan with no cap of its own -- the connection cleanup's Promise.all over this party's unconsumed responsibleFiles -- rest on the entry sweep's term as an assumption (those writes are entries of a directory this party's own poll listing enumerates) rather than on an enforced cap. It would only need revisiting if a fan appeared that no term governs; a bump that changed the trio's size or stopped removing it on settle invalidates the derivation and the boundedness respectively, which is why both are on the checklist below.
  • Raising the ceiling gives up nothing that detected a real leak, because the ceiling never did. It fires at most once per emitter and event name (Node re-arms it when a listener array is rebuilt, so a later fan on the same client may warn again or not at all, and a fresh adapter always warns), and says nothing about whether the listeners came off. apps/cli/test/integration/sharedClientListenerCeiling.test.ts pins the accounting itself instead -- exact peak and exact return to baseline on every event name -- and holds no warning reaching the hook. Its runs reach fans of 9, 40 and 512: a fan of 9 issued after a connection_per_poll release and re-dial (which is also what pins that the emitter each operation attaches to is the one the constructor raised and that a re-dial does not replace it). They also reach the deferred-cleanup drain driven to its full 64 record cap (70 attempted, 64 admitted, 6 refused). Also driven: a shape that leaks by design, attaching one listener per operation and removing none, which the accounting reports and the raised ceiling does not, the strictly-above boundary at the derived value itself, and the two whole-exchange shapes the 3 rests on. The runs' limits: no fan at or near the derived 16453 was driven, nor one at the 8192 listing bound itself, nor a split-scope entry sweep at any width -- the doubled term is derived from the sweep's own construction and the per-listing refusal, not measured at scale. The concurrency term is checked only over the two exchange shapes named above, so a core-side concurrency neither reaches is unmeasured and would cost the spurious warning back. The uncapped responsibleFiles cleanup fan was driven only ad hoc, at 20 unconsumed sends, and is not in the tree, so a bump re-drives it rather than reproducing that sample. The strictly-above boundary's toy-value arm (max 3) was driven ad hoc as well and is not in the tree, the committed check holding that boundary only at the derived value itself. The stdout/stderr split was likewise measured ad hoc, through a child process, and no committed check holds it. And nothing here was driven against a native sshd.
  • A malformed reply to the in-flight request itself is bounded by the adapter's wall-clock deadline, not by cleanupRequests, because ssh2 has already deleted the request from _requests by the time doFatalSFTPError runs. The NAME and DATA response handlers delete it unconditionally before the parse/check that calls doFatalSFTPError, and the HANDLE handler deletes it inside its malformed branch (on a defined request id) immediately before that call. All three leave nothing for cleanupRequests to fail. If a future ssh2 instead deleted after the fatal path (or stopped deleting in the HANDLE malformed branch), cleanupRequests would begin failing in-flight requests too - which would change the mechanism but not break it (the deadline still bounds the operation). The deadline must stay regardless; the liveness fault-injection unit test below proves the current ordering.
  • ssh2-sftp-client's put(src, dest) pipes a non-Buffer src into the write stream (_put's else-branch, rdr.pipe(wtr)), and that write stream consumes under ack-driven backpressure -- ssh2's WriteStream._write calls its stream callback (releasing the next pull) only after the server acknowledges the write. The put liveness idle window rests on both: the adapter hands put a Readable that streams the payload in SFTP_PUT_PROGRESS_CHUNK_BYTES (64 KiB) chunks and resets the window each time a chunk is pulled, so a withheld write ack stalls the pull and trips the window while a slow-but-progressing upload keeps resetting it. It rests further on ssh2's SFTP.write chaining a buffer larger than _maxWriteLen into multiple WRITE packets and firing the stream callback only after the last ack -- which is precisely why the source is chunked rather than handed over whole. A single whole-buffer write reports no progress until completion, making a large legitimate upload indistinguishable from a stall for its full duration. If a future version buffers a provided stream eagerly instead of piping it under backpressure, the window's progress signal would no longer track the server and the bound would need rework; if it merely acks per-sub-write incrementally, the chunking becomes redundant but harmless. This uses only the public stream interface (it does not drive the raw SFTPWrapper), so it adds no new internal coupling beyond these behavioral assumptions. The ssh2SftpAdapter unit tests pin the stall-fires and slow-but-progressing-does-not behaviors and byte-exact upload through the chunked source; the integration suite uploads real payloads through it. The last-ack callback is driven in apps/cli/test/integration/sftpStackPremises.test.ts, alongside two assumptions the chunked source's own teardown rests on: a failed put leaves the caller's source undestroyed, whether the server refused it or the session was lost, so every terminal path destroys it; and the client closes the server-side handle, and rejects the put, only on the source's 'error', so the stall destroys the source with an error rather than bare.
  • A host-key verification rejection (hostVerifier calling verify(false), from either open() verifier in core's fileSyncConnection -- the pinned-key enforce path on a mismatch, or the no-pin fail-closed default) is reported as an Error whose message contains the fragment Host denied, with no machine-readable code set. Two adapter consumers match that fragment. (1) connect()'s retry predicate treats the rejection as terminal -- a non-matching (or absent) pin never becomes a matching one, so retrying only re-runs the key exchange against the same untrusted host. (2) In connection-per-poll mode, ensureConnected's isFatalDialError matches the same fragment to classify a cycle-start re-dial's host-key rejection as fatal (terminate the exchange loud and fast) rather than a transient dial failure to skip-and-retry next tick. If a future version renames the fatal-handshake message (it originates in ssh2's kex.js as Host denied (verification failed)), both consumers stop firing. connect() retries a host-key failure maxReconnectAttempts times before failing with the same outcome (slower and noisier, no security regression, since core's fileSyncConnection re-wraps the same rejection into its SFTP host-key verification failed: message regardless). And isFatalDialError misclassifies a re-dial host-key mismatch as transient and rides it to the peer-inactivity ceiling instead of aborting at once -- a worse failure mode for a possible MITM, though still fail-closed (the exchange terminates, just late and with a generic peer-silence error). Confirm the fragment still appears on the rejection error if either package is bumped.
  • The first-use host-key probe (fileSyncConnection's probeHostKeyFingerprint) and the settleVerify guard around every verifier verdict rest on four ssh2 internal behaviors, all security-relevant. (1) ssh2 invokes the hostVerifier at host-key verification and reaches userauth -- credential transmission -- only after verify(true), so verify(false) aborts the handshake before any password/private key is sent; this is what lets the probe connect with credentials present in its options yet never present them to an unverified host. (2) Returning undefined from the verifier (our void async callback) parks the handshake pending the async verdict. (3) ssh2's readyTimeout stays armed across that park, so the probe's use of the raw (unbounded) transport is still time-bounded. (4) A late verify(), delivered after the handshake has torn down, returns without throwing, whether a readyTimeout, a destroyed socket or an earlier verify(false) tore it down; settleVerify swallows any throw a later version makes there, so a teardown-race verdict cannot become an unhandled rejection. If a future version reached userauth before the verifier, transmitted credentials despite verify(false), or stopped arming readyTimeout until after verification, re-evaluate: each is a security regression (credentials to an unverified host, or an unbounded probe). Also note keyBlob is a Buffer slice view with a meaningful byteOffset/byteLength (ssh2's bufferSlice); hostKeyBlob passes those through, because a bare new Uint8Array(keyBlob.buffer) would hash the whole pooled buffer and compute the wrong fingerprint. Assumption (4) and the slice view are driven in apps/cli/test/integration/sftpStackPremises.test.ts.
  • ssh2 reports none of a peer's pre-identification bytes to the application, so a peer that answers the SFTP port without ever sending an SSH identification string cannot be told from one that sends nothing at all by anything the stack raises. Driven against a bare net listener answering with an HTTP error page, with a TLS alert record, and with a clean close having sent no bytes: ssh2's debug callback -- the one channel that could send them -- reads Socket connected then Socket ended for all three. All three reject identically with Connection lost before handshake (ssh2 sets level: "protocol" on it, which does not survive ssh2-sftp-client's getConnection: wrapper). ssh2 does report pre-identification lines on its greeting event, but only for lines followed by a valid SSH-... identification string, so a peer that never identifies itself emits nothing there either. A peer that resets at accept instead rejects with connect ECONNRESET <host>:<port>, and code: "ECONNRESET" does survive that wrapper. This is why the host-key probe's non-SSH-answer diagnosis (apps/cli/src/connection/sftpPeerIdentification.ts) reads the peer's first bytes itself, on one bounded credential-free TCP connection opened after the dial has failed, rather than threading them out of ssh2. The rejection wording is only the gate on whether to look, so a version that reworded it degrades to the undiagnosed failure and never to a wrong diagnosis. That the wording still arms it is a check rather than prose: apps/cli/test/integration/sftpStackPremises.test.ts dials the pinned client at three bare net peers -- one answering an HTTP error page, one closing having sent nothing, one resetting at accept with resetAndDestroy (an explicit RST: a plain destroy() there, with nothing left to read, arrives as the same clean close). It holds each rejection to the diagnosis's own gate, so a reword fails there rather than disarming the diagnosis unseen. The TLS-alert shape's stack-level rejection wording stays a measured assumption without a check of its own -- the unit suite drives that shape at the classifier, against a bare listener rather than through ssh2. A bump re-measures this: a version that reported those bytes -- on debug, on greeting, or on the error -- would make that second connection unnecessary. The port the diagnosis has to dial when the config sets none -- ssh2's own default, since core omits port from the connect options for a portless config -- is a check rather than prose in the same file. A portless dial of the pinned client at a closed loopback address is refused, the address the stack itself names in that refusal is the port it dialed (connect ECONNREFUSED 127.0.0.1:22), and the diagnosis's resolved probe target for the same portless config is held to it. Nothing there re-derives the default; a version that changed it fails the case rather than sending the diagnosis to a peer the dial never spoke to. Where something answers that port on the host running the suite the stack names no address, and the case skips with that reason rather than assuming one.
  • Both halves of the stack route their own diagnostics to the one debug connect option, as plain strings, one call per rendered line: ssh2's handshake and packet lines, and ssh2-sftp-client's CLIENT[...] lines, arrive on the same callback. That is what the SFTP wire trace routes into Alcove's logger (apps/cli/src/connection/sftpWireTrace.ts), and the lines an operator dials for -- the peer's identification string, both sides' KEXINIT name-lists, the negotiated algorithms, and Handshake completed -- are among them. Which of those strings arrive already escaped is not uniform and is the reason Alcove escapes at its own sink rather than trusting the stack's: driven against a listener choosing every pre-key-exchange byte, ssh2 renders the peer's identification string through inspect and so escapes an ANSI sequence in it, which Alcove's own escape then reads as a backslash and doubles, while it renders the peer's KEXINIT name-lists verbatim, control bytes included. So an operator reads \\x1B on the identification line and \x1b on a name-list, and the difference is which side escaped first rather than anything the peer did. Both, and the credential silence -- no password reaches the callback on the password or the keyboard-interactive path -- are checks rather than prose in apps/cli/test/integration/sftpWireTrace.test.ts, which drives a real dial at the trace level and at the debug, info and warn levels below it, debug again under the highest -v count. The error and silent levels are held by the unit suite's level table (apps/cli/test/unit/connection/sftpWireTrace.test.ts), which resolves the trace against a logger at each level and dials nothing. A version that renamed the option, changed the callback's arity, stopped emitting the handshake lines, or moved a name-list behind its own escape fails there rather than degrading an operator's diagnostic unseen. The last of those lines arrive as the transport drains, after ssh2-sftp-client has answered a close it had no connection open for, and against the pinned stack none of them is emitted later than the ssh2 Client's close event -- which is what the adapter's terminal close waits for, so they reach the operator's --log-file before a command closes its diagnostic sink. The wait is bounded, on a timer that holds the process alive because the socket it waits over may already be destroyed, and the trace detaches where that close returns rather than on the close event -- so a line the stack emits between the event and the close's return is still traced, and only one emitted after the close has returned is dropped rather than printed past the sink. The same test drives all three: a dial the server accepted and one it refused with an SSH_MSG_DISCONNECT held to their last line with nothing on stderr, and the bound measured in a child process with nothing else on its event loop.
  • ssh2 authenticates the keyboard-interactive method by emitting a 'keyboard-interactive' event on the Client with (name, instructions, lang, prompts, finish) and awaiting a finish(answers) whose answers count equals the prompts count (the SSH num-responses == num-prompts rule, RFC 4256, enforced by the server, not by ssh2's authInfoRes). Core sets tryKeyboard -- from connection.server.keyboard_interactive, only alongside a password -- and the adapter registers one persistent listener on this.client (attachKeyboardInteractive) that answers every prompt with that password, for a server that disables the direct password method but accepts the same secret over keyboard-interactive. Every prompt of the request gets the same answer, one answer per prompt: neither the prompt text nor its echo flag is consulted, so the count the rule above requires holds by construction rather than by a fixed answer list. The value is read when the request is answered, off the connect options the most recent dial stored, rather than captured when the listener was attached -- the listener outlives every dial and each dial replaces those options, so a re-dial answers with the credential of the dial it belongs to and never a stale one. Nothing is re-resolved at that point: a recovery or cycle-start re-dial reuses the options the last connect() was called with, so the configuration is not re-read, a @path reference is not resolved again, and the operator is not re-prompted; only a fresh connect() carrying different options changes the answer. Those retained options hold the credential for the adapter's whole life, teardown included, which is what lets a recovery re-dial authenticate at all and is why nothing clears it after an answer. Four ssh2 behaviors hold it up. (1) 'keyboard-interactive' is offered only when tryKeyboard === true, and a server info-request reaches this listener only after userauth begins -- hence only after verify(true), the same post-host-key-verification boundary the probe bullet above rests on, so the password never reaches an unverified host. (2) ssh2 auto-responds to a zero-prompt USERAUTH_INFO_REQUEST itself, before emitting, so the listener never sees an empty prompts (its prompts.map always yields at least one answer). (3) Client.connect() and Client.end() do not strip Client-level user listeners (only sock.removeAllListeners('data') on the socket), so the listener attached before the first connect survives every reconnect -- which is why it is attached exactly once behind a boolean guard, not the object-identity guard the recreated SFTPWrapper's 'error' listener needs. (4) tryKeyboard with no registered listener stalls the handshake to readyTimeout (the emit is a no-op, authInfoRes is never sent), so the adapter throws loud at connect if the Client no longer exposes on() rather than degrading to a silent timeout. If a future version reorders the emit args, stops auto-answering the zero-prompt request, begins stripping Client-level listeners on connect (the boolean guard would then leave the listener un-reattached and stall to timeout), or reaches userauth before host-key verification (a security regression -- credentials to an unverified host), re-verify. The real-server integration test (apps/cli/test/integration/backendAgnostic/keyboardInteractive.test.ts) authenticates through this path against a server that refuses the password method, so a behavioral break fails it red.
  • ssh2-sftp-client's constructor takes the error/end/close event callbacks as its second positional argument and runs them through globalListener, which invokes a callback only for an event the high-level client did not itself initiate (its endCalled / *Handled guard) and performs its own handled-flag bookkeeping and this.sftp teardown regardless of the callback body. The adapter passes explicit callbacks there to route those out-of-band ssh2-Client events to its project logger -- error at error level while a session is held and at trace otherwise, the benign first-use-probe / verify(false)-rejection end/close at trace -- instead of the library's default console.error / console.log (whose bare Global ... listener lines otherwise leak past the logger and the suite's log-level controls). The error message is server-controlled (ssh2 builds it from the SSH_MSG_DISCONNECT description), so it is rendered through sanitizeErrorForDisplay -- escaped against log injection and run through the PEM/key redaction safety check -- before it reaches the logger and any --log-file; on a bump, confirm no credential can ride a Client-level error to this sink (today none can). Which of the two levels an error takes turns on this.sftp: the library hands such an error over with sftp still set, measured for a reset that takes a live session, and a teardown the peer answers with a reset arrives the same way; it has already cleared sftp for one arriving behind a failed dial or a closed connection (measured against a real server through the adapter; both arms are held by apps/cli/test/integration/outOfOperationClientError.test.ts). The split is what keeps a refused key exchange to one reported failure: a real OpenSSH server writes its disconnect and closes, and the client's own disconnect -- written as it reaches the same verdict locally -- lands on a socket already closing, which answers with a reset the client reads as read ECONNRESET once the dial has already rejected (measured against the hardened-sshd harness's restricted-crypto profile, 39 of 40 dials; a bare net client that stops writing after its own KEXINIT sees a clean close 30 of 30 instead, which is what identifies the trailing write as the cause). The routing is observational only: it changes where the diagnostic goes, never control flow. If a bump changes the constructor signature, drops the second-argument callbacks, or makes a callback required (e.g. moves the this.sftp clear into the close body), re-verify; the worst case is the cosmetic console lines returning, not a correctness break.
  • ssh2 builds its key-exchange offer once at module load and runs no capability probe over it, unlike its host-key formats: on a host whose OpenSSL provider does not offer X25519 it still advertises [email protected] and curve25519-sha256, wins the negotiation against a server that offers them, sends KEXECDH_INIT, and only then dies on crypto.generateKeyPairSync('x25519'). apps/cli/src/connection/sftpKexCapability.ts subtracts the unperformable algorithms from that offer, and rests on four measured properties of the pinned ssh2, all decided by reading the client's own SSH_MSG_KEXINIT off the wire rather than from its source. (1) algorithms.kex accepts a modifier object as well as an explicit list -- the append/prepend/remove shape @types/ssh2 declares as AlgorithmList<T> -- and remove matches a RegExp entry as well as a string, mid-string and honouring the i flag, which is what lets Alcove express "ssh2's own offer minus everything built on X25519" without enumerating a key-exchange list of its own. (2) remove is applied after append/prepend, so merging Alcove's removal into an operator's modifier cannot be undone by the entry beside it. (3) An empty explicit list is read as unspecified and falls back to ssh2's full defaults -- so an operator list filtered to nothing must be refused, never forwarded, or the refusal would restore the very algorithms it rejected. A value that is neither a list nor a modifier (a bare string, null) is likewise inert and falls back to the defaults, as is a list that arrives empty -- which drops nothing, so it is not the refusal's case but the inert one, and takes the removal modifier ssh2's own reading of it amounts to. (4) ssh2 appends ext-info-c and the Terrapin strict-key-exchange marker [email protected] to whatever list is offered, and remove does not reach them, so constraining the offer cannot downgrade the handshake.
  • A key-exchange negotiation that then finds nothing in common is reported through ssh2-sftp-client as getConnection: Handshake failed: no matching key exchange algorithm -- ssh2's structured level: "handshake" does not survive that wrapper, so the message fragment is what there is to match on, exactly as for Host denied. Two things key on that fragment, each conditioned on a non-empty capability verdict and never on the message alone. It adds a diagnostic naming the missing primitive, and it feeds one classifier that says the failure is permanent, which both dial paths consult. At connect() the retry predicate ends the dial at its first attempt, because a process that cannot perform the primitive puts the same withheld offer to the same server on every re-attempt, a second apart, for the whole reconnect budget, to arrive at the diagnostic the first attempt already had. In connection_per_poll mode ensureConnected's isFatalDialError terminates the exchange instead of skipping the cycle, because that dial is re-attempted tick after tick until the peer-inactivity ceiling ends the run with a generic peer-silence error, in place of the diagnostic the first cycle already had. The two classify on opposite sides of the diagnostic -- the retry predicate reads ssh2's rejection, the cycle-start re-dial reads what connectLocked threw after the diagnostic replaced that message and kept ssh2's one cause link down. So the classifier answers for the diagnostic's own type as well as for the fragment, a value nothing outside sftpKexCapability.ts constructs and no party on the wire can write. One classifier over one verdict is what keeps the two paths from disagreeing about a single rejection. A reworded message therefore degrades to ssh2's bare text, to that spent budget, and to a connection_per_poll run spending its whole peer-inactivity ceiling on the same permanent failure -- the behaviour of a match that never fires -- never to a wrong diagnostic, a dial ended for the wrong reason, or an exchange ended for one. The fragment is not ssh2's own text alone: ssh2 renders a server's SSH_MSG_DISCONNECT description into that same message, so a server -- or an on-path attacker, since a disconnect precedes host-key verification -- writes the fragment verbatim and reaches the diagnostic at a stage where it does not apply (measured). What bounds that is the capability verdict every consumer is conditioned on, this process's own reading of its own crypto provider taken before the dial. On a host that can perform everything ssh2 offers, a written fragment decides nothing at all -- not the diagnostic, not the dial, not the exchange -- and on a host that cannot, the same party reaches the identical outcome with no message control by offering only algorithms built on the missing primitive. The residual is a misattributed diagnostic, a dial ended at the first attempt rather than after the reconnect budget, and -- in connection_per_poll mode -- an exchange ended at the first cycle rather than at the peer-inactivity ceiling. All three come from a party that could already deny the exchange outright, and one written disconnect suffices, since the classification reads each rejection alone rather than a pattern across ticks. That residual is bounded by the diagnostic's message being composed from constants, with the server's own text one cause link down where the display sink escapes it once.
  • apps/cli/test/integration/sftpKexOffer.test.ts drives all of it against the installed ssh2. Each offer shape -- no operator algorithms.kex, an explicit list, a modifier whose append/prepend names an unperformable algorithm, and a list that arrives empty -- is decoded off the wire with the capability verdict forced, both appended markers surviving every one of them, and the empty-list and non-list fallbacks are measured against bare ssh2 rather than assumed. The constrained offer negotiates against the suite's real server on three of the four dial paths (a direct connect, core's host-key probe, and a recovery re-dial after a server-cut session, the last two read through a recording relay that also holds the re-dial's offer identical to the first dial's). The no-common-algorithm refusal is driven against a real ssh2.Server restricted to curve25519-sha256, with a listener that writes the failure description itself, holding its bytes out of the diagnostic. The terminal classification is driven at the connect loop -- a dial given a reconnect budget of 3 and accepted exactly once by a counting relay, against that ssh2.Server on every leg and against a real OpenSSH sshd on the restricted-crypto native profile -- so a bump that changed any of the four fails it red. Its limits: the fourth dial path, the connection_per_poll cycle-start reconnect (reestablishSession), is driven in apps/cli/test/integration/sftpKexRedialPolicyFlip.test.ts rather than here, its offer and its classification alike. A live connection-per-poll exchange whose endpoint accepts the handshake it opens on and then offers only curve25519-sha256 ends at the first re-dial meeting that policy, with the diagnostic that names the primitive rather than the TransportOperationStalledError the peer-inactivity ceiling raises. The identical refusal under an empty verdict, though, skips the cycle, and the exchange survives to deliver on a later one. The verdict is the only difference between the two arms, the transient one withholding the same algorithms through an operator algorithms.kex of its own, which each arm reads off the wire rather than assuming: the exchange dials through the same recording relay the offer legs above read their packets from (apps/cli/test/integration/kexOfferWire.ts). Every cycle-start re-dial that completed a handshake there offered exactly what the dial the exchange opened on offered -- no X25519 algorithm, both appended markers -- so re-constraining the retained, already-constrained options is measured to be the no-op that the constraint's sitting at connectLocked, the single path to ssh2's connect, predicts of it. What that leaves inferred is the offer of the dial meeting the flipped policy, the one dial of the exchange that abandons its handshake. Such a client can have its socket destroyed with its own SSH_MSG_KEXINIT still unread (measured -- the same property that makes the dial counts below connections accepted), so the packets decoded are those of the re-dials that complete, and the refused dial's offer rests on its being built from those same retained options. That leg has limits of its own. The policy flip is a second listener that a relay in front of the exchange hands later dials to, rather than one server changing its own policy. That is because the pinned ssh2 reads a Server's algorithms.kex once, as the Server is constructed, and never again per connection (measured: an accessor on the config object fires before the first client arrives and not after). And it runs on the in-process backend alone, as the connection-per-poll coverage does throughout, a native sshd not being driven in that mode and the restricted-crypto profile accepting only what the forced verdict withholds, so no dial of such an exchange could complete against it.
  • What the stubbed client in apps/cli/test/unit/connection/sftpKexFastFail.test.ts still holds is the cases no server produces on demand: a host-key rejection, an ordinary transient dial failure, and a dial the teardown settled. The capability verdict is forced rather than produced by a FIPS host, because the only lever this image has is crypto.setFips(true), which is not a model of one. Measured on node 26.5.1 / openssl 3.5.7 it leaves OpenSSL with no usable algorithms (createHash('sha256'), createCipheriv('aes-128-gcm', ...) and generateKeyPairSync('ec', {namedCurve: 'prime256v1'}) all throw error:0308010C:digital envelope routines::unsupported), where the Amazon Linux 2023 FIPS host measured in the next bullet keeps those three primitives and is short only the curve25519 kex pair; an unmeasured provider's behavior on them is not established. The no-common-algorithm refusal reaches a native sshd on one leg only, the restricted-crypto profile, whose server accepts only curve25519-sha256, exactly what the forced verdict withholds. So on that leg the two dials that must complete a handshake skip instead, and on every other leg the refusal and the classification are driven against the in-process ssh2.Server alone. That leg also determines which text reaches error.message there, which is a race rather than a reading. A real OpenSSH server describes its own SSH_MSG_DISCONNECT as no matching key exchange method found -- method, not the algorithm the fragment matches -- and the pinned ssh2's local negotiation failure wins the race. So a bump that let the server's text win would leave the fast-fail never firing against OpenSSH at all, which is the deployment, and fails that leg red. The dials are counted as connections accepted rather than as offers decoded, because a client that abandons a handshake can have its socket destroyed with its own SSH_MSG_KEXINIT still unread by the relay (measured). Both fast-fails rest on that same one leg for the text question, the cycle-start re-dial's no less than the connect loop's: it matches the diagnostic, which exists only where the fragment already matched, so a bump that let a server's own description win the race disarms both at once.
  • The pinned ssh2 does capability-probe the cipher list it offers, as it does its host-key formats and unlike the key-exchange list above, so the ciphers a FIPS runtime puts on the wire are constrained by the provider rather than by configuration. Measured on a real Amazon Linux 2023 host in kernel FIPS mode, running the FIPS variant image built from Dockerfile.fips on aarch64 -- OPENSSL_CONF=/etc/alcove/fips-only.cnf, the certified module it pinned when the capture was taken, 3.0.8-d694bfa693b76001, mapped with crypto.getFips() reading 1 and the image's own engagement probe returning ENGAGED, node v26.7.0 on OpenSSL 3.5.7, its startup report reading the host kernel's FIPS mode as enabled. The measurement decodes the client's own SSH_MSG_KEXINIT off the wire from a probe-host-key dial against a listener that reads that first packet and answers nothing. Both encryption_* lists contain [email protected], [email protected], aes128-ctr, aes192-ctr and aes256-ctr, and not [email protected], with no connection.provider_options supplied at all (probe-host-key builds its connection from the URL alone). The control is the same stack off a FIPS provider -- same architecture, same node and OpenSSL, [email protected], a bare ssh2.Client with no algorithms -- whose offer is those five in the same order plus [email protected]. The provider is the only difference between the two offers, and Alcove configures algorithms.kex (sftpKexCapability.ts) and never algorithms.cipher, so the omission is the runtime's rather than Alcove's or an operator's. That is why the cipher list determines what the kex list cannot, the FIPS host's kex offer being short the curve25519 pair that Alcove's own removal above already accounts for. The same packet holds the host-key probe (ssh-ed25519 absent from server_host_key_algorithms where the control offers it, the six remaining formats identical and in the same order) and shows the probing is per-algorithm rather than a blanket FIPS filter over the offer. Both mac_* lists still contain [email protected] and hmac-sha1, identical to the control's, so the MACs stay the operator's algorithms.hmac to constrain. Its limits: the drop is read off the wire and not from the module -- the engagement probe exercises AES-256-GCM, HKDF, HMAC-SHA-256, SHA-256, MD5, RSA-1024 keygen and X25519, never ChaCha20-Poly1305 -- so what is measured is that the offer tracks the provider, not that the module refuses that cipher by name. The listener answers nothing, so the measurement is the offer, with no cipher negotiated to a completed handshake on that host. And whether the probe also filters an operator-supplied algorithms.cipher list or modifier is unmeasured, so a cipher an operator names and the provider lacks is not known to be withheld the way this default one is. Nothing in the tree drives it -- apps/cli/test/integration/sftpKexOffer.test.ts decodes offers off the wire but forces the capability verdict, and no runtime in CI has a FIPS provider to produce one -- so re-verify it on a bump by re-driving that capture on a FIPS-mode host.
  • ssh2-sftp-client's delete(path, true) resolves rather than rejects when the path is already gone (SSH_FX_NO_SUCH_FILE), the notFoundOK contract the connection-per-poll cleanup drain's re-issue rests on. It is what makes the drain's re-issue safe to tear at an idle boundary (see SFTP_SESSION_RECOVERY.md): a torn delete leaves the file either unlinked or present, and the re-issue that follows must read the first as the success it is rather than spending the record's re-issue budget on it and reporting a file left behind that is gone. This one does not need re-reading on a bump: it is driven against the real in-process server by a case in apps/cli/test/integration/ephemeralSessionExchange.test.ts that records a temp path never created on disk and measures the re-issue clearing the record, so a bump that changed the contract fails the suite rather than silently weakening the drain.

Dependency source files to re-read on an upgrade:

  • node_modules/ssh2/lib/client.js: sftp() and its inner removeListeners() / onReady -- confirm the setup-time 'error' listener is still stripped before the wrapper is handed back. Confirm Client.end() still emits 'close' (not merely 'end', and without needing a separate destroy()), the event releaseForIdle awaits and the one that fires ssh2-sftp-client's globalListener to clear this.sftp for the connection-per-poll release. And confirm the Client still emits that 'close' when its socket is destroyed under it, which is what all three forced closes -- the idle release's, the mid-exchange recovery's, and the terminal one's -- drive on a partner that withholds its own. Confirm too that a peer-side disconnect still emits the Client's 'end' before that 'close', the ordering the recovery's transport retirement reads to tell a transport whose events have all been delivered from one still owing its 'close'.
  • node_modules/ssh2/lib/protocol/SFTP.js: doFatalSFTPError (still emits 'error' on the wrapper, then destroys and calls cleanupRequests), the NAME and DATA handlers (still delete the in-flight request from _requests unconditionally before the parse/check that calls doFatalSFTPError) and the HANDLE handler (still deletes inside its malformed branch, on a defined request id, immediately before that call). So cleanupRequests does not fail a reply-to-self in-flight request in any of the three cases. Also relevant: STATUS_CODE and OPEN_MODE (still the values above), and the handle-path readdir EOF contract. Also relevant: WriteStream._write / SFTP.write (the stream callback still fires only after the server acks, and an over-_maxWriteLen write still chains into multiple packets acked at the end -- the ack-driven backpressure and no-incremental-progress assumptions the put idle window's chunking rests on).
  • node_modules/ssh2-sftp-client/src/index.js: confirm each of the following. The wrapper is still reached via this.sftp with the lifecycle above. The underlying ssh2 Client is still held on this.client (the setNoDelay, _sock.setKeepAlive, and _sock.destroy boundaries), with this.client._sock still a net.Socket. realPath is still a public method (the heartbeat's no-op keepalive). addTempListeners still prepends its per-operation listeners, and its _resetEventFlags/eventHandled bookkeeping still suppresses the earlier operation's listener once a later one has consumed the event (the ordering the concurrent-operation crossing above rests on), while removeTempListeners still removes by identity (what leaves a survivor its own listeners). The trio in question is still exactly one 'end', one 'close' and one 'error' listener per in-flight operation, and all three are still removed when it settles; the count is what the raised listener ceiling's derivation multiplies out, and the removal is what makes the stacking bounded rather than a leak (see the listener-cost measurement above). this.sftp is still assigned only from a connect() whose "already connected" guard passed (the captured-wrapper assumption, and the reason a repeat connect() over a live session is refused rather than replacing it, held by apps/cli/test/integration/sftpStackPremises.test.ts). It is still cleared only from events implying the transport is ended (the per-operation temp 'end'/'close' listeners, which settle their operation from the same pair -- so a high-level operation is torn from the 'end', ahead of the 'close' the transport still owes -- and the constructor globalListener's 'close'), and never cleared while the transport is still live. A clear that outlives the transport is what would admit a dial into ssh2's deferring state above, and an assignment that outlives it is what would swap a live operation's captured wrapper. end() still resolves only from the ssh2 Client's 'close' (the assumption the terminal close's bound exists for), and still rejects from its temporary 'error' listener without having closed the transport (the assumption the forced close on a rejecting end() exists for). rename and put still pass the raw numeric status through fmtError onto err.code, so a server SSH_FX_FAILURE is reported as err.code === 4 (the assumption the rename and put retries gate on; the put statuses are held by apps/cli/test/integration/sftpStackPremises.test.ts). _put still pipes a non-Buffer src into the write stream (rdr.pipe(wtr)) rather than buffering it (the assumption the put idle window's progress signal rests on). And the constructor still accepts the error/end/close callbacks as its second positional argument, run through globalListener, which still does the handled-flag and this.sftp bookkeeping itself (the boundary the adapter routes off the console) and still hands a live session's error to that callback with this.sftp not yet cleared (the reading the adapter's error-level routing turns on).
  • node_modules/ssh2/lib/protocol/constants.js / protocol/kex.js: the key-exchange offer's assumptions are driven rather than read (apps/cli/test/integration/sftpKexOffer.test.ts decodes the client's own SSH_MSG_KEXINIT), so read these only to explain a failing case. Confirm algorithms.kex still takes the append/prepend/remove modifier with remove applied last and matching a RegExp, that an empty explicit list still falls back to DEFAULT_KEX, and that ext-info-c and [email protected] are still appended outside that filtering. Also confirm that a negotiation with nothing in common still throws with a message containing no matching key exchange algorithm (the fragment with both the diagnostic and connect()'s terminal classification of an unperformable key exchange).
  • node_modules/ssh2/lib/protocol/kex.js: confirm a host-denied handshake failure still throws with a message containing Host denied (the fragment both connect()'s terminal-on-host-key-rejection retry predicate and connection-per-poll mode's isFatalDialError match, since ssh2 sets no code on it). Confirm the hostVerifier is still invoked at host-key verification with service('ssh-userauth') reached only afterward via onHandshakeComplete (the credential-non-disclosure assumption the probe rests on), and that returning undefined from the verifier still parks the handshake pending the async verdict.
  • node_modules/ssh2/lib/client.js: confirm readyTimeout is still armed at socket connect and not cleared while an async hostVerifier is parked (it bounds the probe's raw, budget-unwrapped transport); confirm a connect() issued on a still-writable socket is still deferred behind once('close', ...) with no readyTimeout armed for the deferred attempt (the assumption the dial-gate reasoning above rests on). Also, for keyboard-interactive: confirm the userauth path still emits 'keyboard-interactive' on the Client with (name, instructions, lang, prompts, finish) (the curAuth.prompt -> this.emit(...) boundary), and still auto-responds to a zero-prompt USERAUTH_INFO_REQUEST without emitting. Confirm too that it still offers 'keyboard-interactive' only when tryKeyboard === true (reached after host-key verification), and that Client.connect()/end() still leave Client-level user listeners in place (only sock.removeAllListeners('data')) so the once-attached listener survives reconnects. The recovery's 'end'/'close' watch, attached once per adapter on the same boundary, rests on that equally, since a watch dropped on a reconnect would leave its reading stale rather than absent. node_modules/ssh2/lib/protocol/handlers.misc.js: confirm USERAUTH_INFO_REQUEST prompts are still parsed as { prompt, echo } objects (an array, so prompts.map is safe).

The dial-on-a-writable-socket assumption re-verifies itself. The dial gates above rest on ssh2-sftp-client never clearing this.sftp while the transport is still live: a clear that outlives the transport would admit a dial into ssh2's deferring state, where no bound applies. apps/cli/test/integration/dialDeferral.test.ts is that assumption as a check -- it drives the three dial paths against a partner that never closes and censuses the socket beneath every dial -- so a bump that re-opened the deferring state fails it red rather than needing a hand-run procedure. It covers the same three paths the measurement did, and no more (see the note's caveats), with the recovery re-dial driven in both states a drop leaves behind -- a session still held over an ended transport, and a 'close' still owed over a cleared one -- so a bump that adds a dial path adds a case there.

The test backend's listing batches rest on a NAME-reply wall. The in-process SFTP server every CLI suite drives (apps/cli/test/sftpServer/inProcessServer.ts) answers a READDIR by packing entries until the reply reaches READDIR_BATCH_BUDGET_BYTES and resuming the listing on the next round trip, because the pinned client refuses a wider reply outright. Measured against a real ssh2 server and the real client, a NAME reply the server declares at 262,144 payload bytes is delivered whole, while one declaring 262,145 raises a fatal protocol error on the client's SFTPWrapper naming that maximum and takes the SFTP session down with it. And the server that wrote it is told nothing either way, so a backend that wrote one would lose the session it was serving without seeing why. The budget is half the wall, the margin that lets the backend's per-entry byte estimate over-count without costing a reply. Both sides of the wall are checks in apps/cli/test/integration/sftpStackPremises.test.ts, driven at the width the server's own encoder declares for each reply rather than at one predicted for it, so a bump that moves the wall fails there and names the stack. A suite driving a wide listing would report the same break as its own batching having stopped working.

Then run npm run test:integration -w apps/cli against the new version. The contract-assertion tests in apps/cli/test/integration/sftpConnection.test.ts pin the zero-listener assumption from both sides (a raw ssh2-sftp-client connect leaves zero 'error' listeners on the wrapper; an adapter connect leaves exactly one), and apps/cli/test/integration/backendAgnostic/keyboardInteractive.test.ts authenticates through the keyboard-interactive path against a server that refuses the direct password method, so a lifecycle or auth-path change fails those tests red rather than regressing silently.

Upgrading the PeerJS Stack (peerjs / peerjs-js-binarypack) ​

The web WebRTC data-channel inbound bound specified in TRANSPORT_BOUNDS.md reaches past the public DataConnection API into PeerJS reassembly/unpack internals (apps/web/src/psi/transport/boundedReassembly.ts) and parses the peerjs-js-binarypack wire format directly (packages/core/src/connection/binaryPackBounds.ts), so it rests on assumptions about both packages' internal behavior that an upgrade can silently break. The outbound path does the same in the other direction: Alcove encodes every frame itself (packages/core/src/connection/binaryPackEncode.ts), because the library's packer recurses once per element and cannot encode a record-scaling frame at all, and the web app installs that encoder over the PeerJS connection's own send internals (apps/web/src/psi/transport/iterativePacking.ts). Re-verify the following on any peerjs or peerjs-js-binarypack version bump, before the bump merges.

A peerjs-js-binarypack bump is one edit across two declaration sites: apps/web/package.json (the web transport's decoder, and the packer its tests drive) and packages/core/package.json (a dependency: the unpacker the PeerJS framing in core decodes with, the real packer and unpacker both of core's differential suites oracle against, and the packer the shared frame fixtures both transports are driven through are built with, packages/core/src/connection/webrtcInboundFrameFixtures.ts). A site left behind puts the wire format the guards are measured against out of step with the one a partner decodes with.

The library is the ORACLE for Alcove's own encoder, not a runtime dependency of it: binaryPackEncode.ts does not import the library, and the encoder's differential suite requires byte equality against the real pack for every frame shape and marker. A bump that moves the encoding fails there rather than at a partner's browser. A scoped no-restricted-imports rule in eslint.config.mjs fails lint when binaryPackEncode.ts or binaryPackBounds.ts imports the library statically, covered by scripts/eslint-binarypack-library-import-ban.test.mjs; a dynamic import() is outside what it matches.

The internal assumptions the bound relies on:

  • The binary/chunked DataConnection class -- the one peer.connect and an incoming connection use by default -- exposes _handleChunk (reassembling chunk slices keyed by __peerData into _chunkedData, deleting the entry on completion), _handleDataMessage (the sole point each frame is unpacked: an unchunked frame directly, the reassembled buffer via the completion recursion), and _chunkedData (the per-id partial store). The bound replaces _handleDataMessage with its own unpack and a copy of the dispatch that follows it in PeerJS: a truthy __peerData whose type is "close" calls close(), any other truthy __peerData goes to _handleChunk, and anything else is emitted as a data event. A frame decoding to null or undefined is emitted too, where PeerJS's own dispatch throws, matching the CLI's receive dispatch. assertChunkReassemblySupported checks all three exist at install time, so a rename or removal fails loud. These three are specific to the binarypack (Binary) serializer class; the Cbor/MsgPack/JSON/None connection classes do not chunk and lack _chunkedData/_handleChunk, so a default-serializer change to a non-chunking class trips the assert. The residual to re-verify by hand, because the assert cannot catch it: a future serializer that also exposes these three names but uses a different (non-binarypack) wire format would pass the assert while the marker scan parses the wrong format -- confirm peer.connect still defaults to the binarypack Binary serializer. A marker misparse either refuses the frame early (fail-closed) or runs the cursor off the end (delegated to unpack, which errors); it never admits a frame it did not walk, but the chunk-byte accounting could mismatch, so this is a re-verify assumption, not a silent-safe one.
  • The same class exposes the send path the outbound encoder replaces: _send(data, chunked) (packs one value, then chunks or buffers it), _sendChunks(packed) (splits an over-MTU frame and sends each envelope back through send already chunked), _bufferedSend(packed) (the point a datagram reaches the data channel, with PeerJS's own buffered-amount backpressure), and chunker.chunkedMTU (the 16,300-byte threshold). assertIterativePackingSupported checks all four exist at install time, so a rename or removal fails loud rather than leaving the recursive packer in place -- a failure that would otherwise show up only on an exchange large enough to reach it, after both parties had paid for the PSI compute. The replacement calls _sendChunks and _bufferedSend unchanged, so PeerJS keeps chunking, buffering and backpressure; only the encode step is Alcove's. It drops the fifth path the original _send has, _send_blob(data) (taken when pack returns a promise for a Blob): Alcove sends no Blob, and one handed to the replacement refuses with a usage-kind ConnectionError from the encoder rather than being packed asynchronously. That narrowing is intended and is driven by apps/web/test/unit/psi/iterativePacking.test.ts; a bump that made a Blob a frame shape Alcove needs would have to restore the path rather than assume it is still there.
  • The Packer's marker choices, which the outbound encoder emits byte for byte: the integer ladder (fixint, then the first unsigned or signed marker whose range holds the value, up to int64/uint64), the double layout taken from a logarithm and a truncating multiply rather than from the IEEE-754 bits, UTF-8 strings under 0xb0 + n/0xd8/0xd9, byte strings under 0xa0 + n/0xda/0xdb, arrays under 0x90 + n/0xdc/0xdd, and plain objects as maps under 0x80 + n/0xde/0xdf with keys packed as strings in Object.keys order. This is the one assumption here that a suite enforces exactly rather than approximately: packages/core/test/connection/binaryPackEncodeDifferential.test.ts compares Alcove's bytes against the real packer's for the four record-scaling frames and every marker at its boundaries, and apps/cli/test/unit/connection/webrtcOutboundParity.test.ts does the same over the shared fixture set. A bump that moves any marker choice reddens there.
  • The chunk envelope shape _handleChunk consumes: __peerData (the message id shared by every chunk of a frame), n (chunk index), total (chunk count), data (slice bytes). The byte/chunk accounting keys on __peerData; a rename collapses every chunk to one in-flight entry -- which over-rejects (fail-safe), not fail-open -- but is unverified by the assert, so confirm the field names on a bump.
  • The BinaryPack marker dispatch the scan mirrors (peerjs-js-binarypack's Unpacker.unpack): fixint/fixraw/fixstr/fixarray/fixmap and the 0xc0-0xdf markers, maps declaring two child values per pair, the raw-vs-str split at 0xa0/0xb0, the fixed scalar skip widths and the u16/u32 length-prefix widths, and unpack_string/unpack_raw advancing the buffer cursor by exactly the declared size regardless of how the payload decodes. A format change either refuses the frame early (fail-closed) or runs the cursor off the end (treated as malformed and delegated), so it weakens the scan's precision rather than disabling it, but re-verify the marker table on a peerjs-js-binarypack bump.
  • The number markers wider than 16 bits (float, double, uint32, int32, uint64, int64) decode to JS numbers rather than a heavier boxed value, so each one costs an array slot and a heap number behind it at most -- inside the per-slot figure the measured amplification envelope in TRANSPORT_BOUNDS.md rests on. A version that decoded the 64-bit markers into a BigInt, say, would retain more per declared node than that figure admits. The retention suite drives both halves -- the decoded type per marker, and the bytes an array of a million of them retains per declared node (packages/core/test/connection/binaryPackRetention.test.ts) -- so read the source only to explain a failure.
  • unpack_raw produces each bin/raw value as a fresh per-value copy through slice(...) of the unpacker's input -- a Uint8Array view when the input is one (the chunked-completion path and the CLI), a bare ArrayBuffer otherwise -- never a window sharing the frame's buffer. That copy's fixed cost is one of the per-value figures the measured amplification envelope in TRANSPORT_BOUNDS.md rests on, so a bump that made it heavier moves that figure. The retention measurement drives it (packages/core/test/connection/binaryPackRetention.test.ts), so read the source only to explain a failure.
  • unpack's past-the-end read returns 0 (a positive fixint) rather than throwing, so unpack_array retains the whole store its declared count reserved whether or not the wire backs it -- the per-slot half of the measured amplification envelope, and the reason the byte-backed-elements and cumulative-element rules tie a declared count to the wire. The length-prefixed read() path (raw/str payloads) does throw on underrun, the cursor-underrun-is-malformed path the scan catches and delegates. If a future version throws on the array zero-fill read instead, the reserved store shrinks to what the wire backs; if the length-prefixed path stops throwing, re-evaluate the delegate-on-underrun reasoning.
  • unpack_map builds a plain object and assigns each pair through map[key] = value, so a key that is not already a string is retained as its coerced property name (and a container key as the joined string forms of the values below it, zero-filled descendants included). The scan's refusal of any non-string map key rests on that assignment form; if a future version keys a Map (or otherwise stops coercing), the refusal becomes a safety check rather than the rule that keeps a frame's retention tied to its wire size.
  • pack emits a map only for a plain JS object, whose own keys are strings by construction, and throws on a Map or Set rather than encoding one. This is what makes the refusal above safe to apply to legitimate traffic, and it is the one assumption here that is enforced rather than only re-verified. The differential suite packs every value Alcove sends on the data channel with the real packer and requires the scan to accept each, so a bump that started emitting a non-string key would redden rather than silently break every exchange.

Dependency source files to re-read on an upgrade:

  • node_modules/peerjs/dist/bundler.mjs (the bundled binary/chunked DataConnection): confirm _handleChunk still reassembles into _chunkedData keyed by __peerData and recurses into _handleDataMessage on completion; that _handleDataMessage is still the sole unpack point both the unchunked and reassembled paths flow through, and that what follows its unpack is still the close/chunk/data dispatch the bound copies; that peer.connect's default serializer is still the binarypack Binary class (and that _chunkedData/_handleChunk remain specific to it); and that the chunk envelope still holds __peerData/n/total/data. On the send side, confirm _send still packs and then dispatches to _sendChunks or _bufferedSend on the chunker.chunkedMTU threshold, and that _sendChunks still routes each envelope back through the connection's own send with the chunked flag set.
  • node_modules/peerjs-js-binarypack/dist/binarypack.mjs (Unpacker.unpack): confirm the marker table above, that unpack_string/unpack_raw advance the cursor by exactly the declared size, that unpack_array sizes its store from the declared count before reading an element, that unpack_raw still copies through slice rather than returning a shared window, and that unpack_map still assigns through map[key] = value. On the Packer side, confirm a map is still emitted only for a plain object with string keys -- the differential suite drives this, so read the source only to explain a failure.
  • packages/core/src/connection/binaryPackBounds.ts: re-confirm readValueHeader/scanFrameStructure still mirror that marker table.
  • packages/core/src/connection/webrtcOutboundBound.ts: the sender-side frame check charges each chunk at PEERJS_CHUNK_MTU plus the widest chunk envelope. A unit test holds the constant to the installed util.chunkedMTU (apps/web/test/unit/psi/webrtcFrameLimit.test.ts); re-confirm the envelope's four keys, since the charge counts their bytes.
  • packages/core/src/connection/binaryPackEncode.ts: the differential suite is the check, so read the source only to explain a failure -- a bump that reddens it means the wire format moved, and the encoder must move with it.
  • apps/web/src/psi/transport/boundedReassembly.ts: re-confirm assertChunkReassemblySupported still probes the three internals, that the wrap still applies the core constants at both of them, and that dispatchDecodedFrame still matches PeerJS's dispatch.
  • apps/web/src/psi/transport/iterativePacking.ts: re-confirm assertIterativePackingSupported still probes the four send internals and that the replacement still hands an over-MTU frame to the connection's own chunker.
  • apps/web/src/psi/transport/signalingBounds.ts: re-confirm peer.socket.start, peer.socket._socket, peer._lostMessages and peer._storeMessage all still exist; that _storeMessage is still the single entry point through which a message is held for a connection not yet set up; and that socket.start still creates _socket synchronously, since the guard reads it immediately after calling start, with no wait for the socket to open.

assertChunkReassemblySupported and assertIterativePackingSupported both run at install time on every connection in openPeerMessageConnection, and the live browser exchange test (apps/web/test/browser/invitedPSI.test.ts, run in CI) installs the guard on a real DataConnection, so a renamed or removed internal fails the install loud rather than running with no inbound bound. The unit tests pin the marker table and the bound constants (packages/core/test/connection/binaryPackBounds.test.ts), the fail-closed wrap bounds (apps/web/test/unit/psi/boundedReassembly.test.ts), and the scan's agreement with the real packer/unpacker from both sides. packages/core/test/connection/binaryPackBoundsDifferential.test.ts drives the scan alone, beside the source and against core's own pin, reading every frame the real pack produces and the value the real unpack returns from it. apps/web/test/unit/psi/boundedReassemblyDifferential.test.ts drives the same agreement through the web reassembly wrap. packages/core/test/connection/binaryPackRetention.test.ts measures what an admitted frame retains under --expose-gc, so a bump that changes how much unpack retains per reserved slot, per string character or per binary value reddens rather than widening the envelope the spec states. A purely behavioral change that keeps the names -- a different chunking serializer, a renamed chunk field, a marker-format change -- is not caught by the assert or the happy-path browser test, so the by-hand assumptions above must be re-verified against the source files on any bump.

The clean close's delivery assumption re-verifies itself. The web transport's clean close waits for the peer to close the data channel rather than returning once PeerJS's flush has queued the in-band close sentinel (apps/web/src/psi/transport/waitForPeerClose.ts, and WEBRTC_TRANSPORT.md for why the sentinel alone is not delivery). It rests on five behaviours, all measured in Chromium against the app's own broker rather than read off the source. An open DataConnection exposes the live dataChannel and peerConnection it declares on its public type. A flushing close leaves the local channel open, so it produces no channel close of its own. The receiving PeerJS peer closes its channel on reading the sentinel, which the ordered channel necessarily places behind every frame already handed to send. The peer connection under a channel that is starting to close is still live when the peer resets the stream, and already gone when this side's own teardown takes it. And open on the DataConnection itself is cleared by every path through DataConnection.close() -- the peer's close sentinel, this side's own close call, a broker-relayed leave, an echoed OFFER, an ICE failure, a send error, or the channel's own onclose. The pin's critical assumption is the ordering: PeerJS closes this side's peer connection and clears the flag synchronously, ahead of the channel's queued closing task, and a flushing close leaves that flag alone, since it only queues the sentinel and returns. Those last two together are what let the wait tell a receipt from a loss, since the healthy ending in which both parties close reaches closing on a link of this side's own closing. A completed close cannot be read that way at all: a link state read after a completed close no longer dates the death, so the wait does not try. apps/web/test/browser/webrtcCloseDelivery.test.ts drives all five on a real pair. The close must resolve no earlier than the peer's read of an 8 MiB final frame, and the same exchange with the wait disabled must resolve before it. A link torn down under a standing drain must report a loss rather than that receipt, and both parties closing a drained exchange must report the receipt on each side rather than that loss. So a bump fails there if it closed the local channel itself, stopped closing on the sentinel, moved either accessor, moved its own teardown ahead of the wait's reading, or changed when it releases open. That failure is what keeps such a bump from silently restoring a close that returns with the exchange's final frame still buffered, one that reports a lost frame as delivered, or one that doubts every healthy exchange. A bump that breaks the ordering reports that spurious loss on the drained-and-closed case above, and one that lets a flushing close clear the flag suppresses the loss the standing-drain case exists to catch.

A repeated OFFER replaces the connection, and the CLI acceptor's re-send rests on it. PeerJS 1.5.5, handed a second OFFER naming a connectionId it already holds for that peer, closes the existing DataConnection -- emitting no close while it is not yet open -- and builds and emits a new one under the same id (the Offer case of Peer._handleMessage in node_modules/peerjs/dist/bundler.mjs, which logs "Offer received for existing Connection ID"). The web app keeps the first connection it is handed, so a CLI data channel that opens on the second is one the app never reads. That is why the CLI acceptor never sends a copy of its offer while the broker may still hold the last one (WEBRTC_TRANSPORT.md). No suite drives the PeerJS half: apps/cli/test/unit/connection/webrtcNegotiation.test.ts pins only the CLI half, against a scripted peer -- one offer until the broker's EXPIRE or the unreported-offer fallback, and one more on each, the one on an EXPIRE no sooner than the minimum offer re-send interval after the last send. Re-measure it on a bump by the run that established it: a CLI acceptor (alcove exchange on a channel: webrtc config) started more than 5 s before a browser inviter in Chromium, both through the broker the web app's dev server mounts (npm run dev), with DEFAULT_UNREPORTED_OFFER_RESEND_MS set below the broker's 5 s hold in a local build so the inviter is handed two copies of one connectionId when it registers. On 1.5.5 the browser's console shows that warning, the connection the web app took is closed before it opens, and the CLI's data channel opens on the replacement, which the app never reads. A bump that ignores or re-answers a repeated OFFER instead makes the rule a precaution rather than a requirement; one that does anything else re-opens the re-send design in WEBRTC_TRANSPORT.md. Then run the same exchange at the default budget and confirm it completes.

Upgrading the CLI WebRTC peer (werift) ​

The CLI's WebRTC transport (apps/cli/src/connection/webrtc/) drives werift's RTCPeerConnection directly -- no PeerJS runs in Node -- and speaks the PeerJS DataConnection wire on the raw data channel. The library choice and the architecture are recorded in docs/notes/cli-webrtc-stack.md; what follows is the pin's own content: the behaviours the transport rests on that werift's published API does not state, and how to re-verify them before a bump merges. Every one was established by driving the library, not by reading it, which is also how each must be re-checked.

The internal assumption: the SCTP send queues ​

The transport's flushing close reads peerConnection.sctp.sctp.outboundQueue and sentQueue -- chunks not yet transmitted, and chunks transmitted but not yet acknowledged. Both empty is the only observable point at which tearing the connection down cannot lose data, and there is no public API for it.

This is not a refinement of the data channel's bufferedAmount. That counter reaches zero while chunks are still unacknowledged, so a close gated on it loses them: measured at roughly one frame in three, nine datagrams of sixty-two, over a loopback channel with no packet loss at all. Gated on the queues, the same case delivered twelve times out of twelve. assertSctpDrainSupported checks both fields exist on every session, so a release that renames or restructures them fails loud rather than silently restoring that data loss.

The close is two-phase, and an upgrade must keep both halves. The frames are drained to acknowledgement; the PeerJS close sentinel that follows is drained only to transmission, because a peer closes the moment it reads the sentinel and therefore never acknowledges one -- waiting for that would spend the whole close budget on every clean close.

The behavioural assumptions ​

  • An iceServers list holding a stun url replaces werift's built-in stun.l.google.com:19302 default rather than adding to it. This is what makes a configured stun list the list actually used. A list holding no stun url, whether empty, absent or holding only turn urls, means "use the default" (it does not mean "no STUN"), and getConfiguration() echoes its input, so it confirms pass-through but not suppression. Suppression is held by a separate check that hooks dns.promises.lookup -- the entry point werift resolves a STUN hostname through -- and asserts a peer built with {}, or with a list holding only a turn url, resolves stun.l.google.com while a peer built with a stun list does not (test/integration/webrtc/transport.test.ts). The default's endpoint is measured in the same suite rather than read off the library: the hostname is resolved to loopback and the real peer's STUN binding request is observed arriving on the port the endpoint names, so the host and the port are both driven. The transport warns when no list is configured, naming the default it is about to use; a list holding only turn urls uses the default without that warning.

That endpoint is a confidentiality statement an operator reads before handing a recurring exchange's secret to a scheduler, and it is written by hand in more than one place: the CLI warning, the web app's command-line export panel (which may not import from another app), and the documents that state the default. It lives once, in WERIFT_BUILT_IN_STUN_URI (apps/cli/src/connection/webrtc/weriftPeer.ts), and npm run check:stun-default-claims holds every copy to it, so a bump that moved the default cannot leave one behind. The web app's own ICE list (apps/web/src/psi/transport/rendezvous.ts) is a different list and is not tied to it.

  • ICE candidates begin firing during setLocalDescription, before the local description can have reached the broker. Browser PeerJS has no pre-remote-description candidate queue of its own and discards a CANDIDATE it cannot apply, silently from the sender's side, so the transport queues its own candidates until its description is on the wire. werift also inlines its candidates in the SDP, which masks a trickle loss entirely -- a broken queue therefore shows up as a connection that works until the inlined candidates are not enough, never as an end-to-end test failure. That is why the queue's ordering is pinned by a unit test against a scripted peer rather than by the live suite.
  • A peer that vanishes is detected in about thirty seconds, by consent freshness, and not before. Until then connectionState still reads connected, the data channel still reads open, and bufferedAmount stays pinned at its peak. A partner whose peer connection is closed tells this side, which raises the data channel's close at once while connectionState still reads connected; a partner that crashes or drops off the network sends nothing, and that is the case consent freshness alone detects. So the transport watches connectionState to fail an exchange whose partner has gone; without it a party would wait out its hour-scale inactivity budget.
  • send accepts a Buffer/Uint8Array and onmessage delivers a Node Buffer, and pack encodes a Uint8Array, an ArrayBuffer and a Buffer to identical bytes, so the framing is unambiguous in both directions.
  • setRemoteDescription and addIceCandidate accept plain objects -- {type, sdp} and the browser-shaped {candidate, sdpMid, sdpMLineIndex, usernameFragment} -- which is what lets a broker payload be applied without reconstructing a library type. An unparseable candidate throws a DOMException, so a malformed one from a peer is catchable rather than fatal.
  • A remote host candidate named <uuid>.local, the form a browser gives its host candidates by default, is resolved by an mDNS query, and one no host answers costs 10 s. Until the remote description has signalled end-of-candidates, werift 0.25.0's addIceCandidate does not settle until the query is answered or 10 s pass (after an a=end-of-candidates it skips such a candidate without a query and settles at once); an unanswered candidate is then dropped without an error and never reaches the ICE agent or getStats, and a candidate handed over after it is kept. So the transport hands held candidates over without waiting on each (WEBRTC_TRANSPORT.md). Driven with two werift peers through the broker, the partner's description followed by two unresolvable .local candidates and then the one usable srflx candidate, the channel opened about 20 s after the description when each held candidate waited on the one before it, and about 0.2 s after it without (test/integration/webrtc/heldRemoteCandidates.test.ts). A lookup still pending when its peer connection closes keeps its timer, which holds the Node event loop until the 10 s are up: measured on a bare peer connection, the loop drained about 9.8 s after the close.
  • The negotiated SCTP maxMessageSize is 65,536 bytes between two werift peers. The transport does not rely on it: PeerJS's own 16,300-byte chunking threshold is well below it, and the inbound bound refuses an over-cap datagram regardless of what the SDP negotiated.
  • The work werift does on each acknowledgement grows with the messages handed to the channel and not yet transmitted, and bufferedAmount falls only once its whole SCTP send queue has been transmitted. Each message handed over waits on one shared event that fires on every acknowledgement, and removing each waiter scans the rest, so handing over a whole 17.5 MB frame (1,074 messages) took about 60 s and starved ICE consent until the connection failed. Measured on one host over loopback, unpaced, a 35 MB set took about 344 s and the response transfer of a 500,000-record round lost the connection every time; paced, the 17.5 MB and 35 MB sets take about 1 s and 2 s, and a 70 MB set about 5 s. The CLI's send path therefore paces to a window on bufferedAmount and the bufferedamountlow event (WEBRTC_TRANSPORT.md); because the counter drops in one step, the event fires once per window. If the event stopped firing, a waiting send would still re-read the channel every 250 ms, so the failure is slower transfer rather than a hang. Re-check on a bump with the WebRTC completion run (apps/cli/test/stress/webrtcCompletion.stress.test.ts) at 1,000,000 records a side: it completes in about three minutes on a development host, and a regression shows as a set transfer of minutes or a lost connection.
  • A peer answers consent checks, and reads the answers to its own, only from the event loop of the thread that owns it, and two parties' holds of that thread add up. A party whose main thread runs synchronous work neither answers its partner nor counts its partner's answers, and a party coming out of such a stretch does not start over: its partner's hold that follows is counted on top of its own. Measured on one host over loopback at 7,700,000 records a side, each party built its first cascade round's set in one stretch of about 27 s, the acceptor first and the inviter straight after it, and the acceptor's connection went from connected to failed about 29 s after its own stretch began, 2.4 s into the inviter's; with the set built in short stretches, the two parties' 26 s stretches resolving the round's matches, again one straight after the other, ended the connection the same way. The exchange therefore builds and resolves a cascade round in short stretches with the event loop run between them (WEBRTC_TRANSPORT.md). Re-check on a bump with the WebRTC completion run (apps/cli/test/stress/webrtcCompletion.stress.test.ts) at its default 7,700,000 records a side on a host that can hold both parties: it completes in about twenty minutes, it holds each party's longest stretch on an open channel to a stated bound, and a regression shows as a lost connection in the first round.
  • bytesSent and messagesSent are counted at hand-off, not as bytes leave, so neither is a progress gauge; there is no finer signal than the queues above.
  • getStats() resolves a Map whose entries name their fields as the W3C statistics dictionaries do: a type of local-candidate, remote-candidate or candidate-pair, an id, a candidateType, and a pair's localCandidateId, remoteCandidateId, state and nominated. The ICE diagnostics an operator is shown are read entirely out of those names (apps/cli/src/connection/webrtc/iceDiagnostics.ts), and a rename would empty the report rather than fail it, so a live pair's report is asserted in the transport suite.
  • Closing the peer connection disarms a TURN allocation's refresh timer. werift keeps an allocation alive by re-sending refresh on a timer armed from the lifetime the relay granted, and a timer left armed after the close holds the Node event loop for five sixths of that lifetime -- about 500 s where a relay grants the usual 600 s. werift 0.24.4 left it armed (8 min 19 s from close to the timer firing, measured against coturn), and nothing it exposed released it. On the pinned release the close disarms it: a peer connection that allocated against a scripted UDP relay granting 120 s let its event loop drain 1 ms after its close, where 0.24.4 held it 86 s. The relay still holds the allocation until the lifetime it granted ends, since the close sends no refresh releasing it (WEBRTC_TRANSPORT.md). A release that leaves the timer armed again brings the held loop back, and the process then returns on the run's own budget rather than on the timer, naming the handle still armed.
  • werift refuses a DTLS certificate whose fingerprint the SDP did not name. The CLI's webrtc channel applies the application-layer encryption (CHANNEL_SECURITY.md) above the DTLS session, which still protects the channel against the network path, so a session to any certificate other than the one a description's a=fingerprint line names must not complete. Driven with two real peers on loopback, one side's description handed over with its fingerprint value changed in one digit, werift 0.25.0 ends with a peer's connectionState at failed, no data channel opened and no message delivered, whichever of the offer and the answer is changed; the same pair with the descriptions unaltered opens both channels and delivers a message each way (test/integration/webrtc/dtlsFingerprint.test.ts).
  • werift keeps the first TURN url and the first STUN url of the iceServers list and drops the rest. Its parseIceServers, given a list of two STUN urls and two TURN urls, returns options holding the first STUN address and the first TURN address with its username and credential only. A CLI run therefore gathers relay candidates from the first TURN url it lists and server-reflexive candidates from the first STUN url, as PROTOCOL.md states. The parser test (test/unit/connection/webrtcIceConfiguration.test.ts) pins what parseIceServers keeps. The integration test (test/integration/webrtc/firstTurnUrl.test.ts) shows what a real peer contacts: a peer given two STUN and two TURN loopback servers, each answering with an error at once, sends requests to the first of each kind only by the time its gathering completes.

Re-verification on a bump ​

  • Run npm run test:integration:webrtc -w apps/cli -- test/integration/webrtc/transport.test.ts. It drives two real werift peers over loopback through the real vendored broker and covers the chunked round trip, the final-frame-before-close guarantee, the iceServers pass-through, the STUN-default suppression (observed through werift's DNS resolution, since no readable API shows it), the endpoint that default addresses, the inbound bound, the ICE statistics the diagnostics read by field name, and that a close still returns when the partner has vanished -- a partner cut off from the network before it closes, since its close would otherwise tell this side. A bump that moved the default reddens there first; npm run check:stun-default-claims then names every copy of the old endpoint still to be updated.
  • Run npm run test:integration:webrtc -w apps/cli -- test/integration/webrtc/dtlsFingerprint.test.ts for the fingerprint check. A bump that accepted a certificate the description did not name reddens there.
  • Run npm run test:integration:webrtc -w apps/cli -- test/integration/webrtc/firstTurnUrl.test.ts and npm run test:unit -w apps/cli -- test/unit/connection/webrtcIceConfiguration.test.ts for the one-TURN-url, one-STUN-url assumption. A bump that contacts a further url reddens in the integration test, and the stated limit in PROTOCOL.md is then updated with it; the other documents link to it.
  • Run npm run test:integration:webrtc -w apps/cli -- test/integration/webrtc/heldRemoteCandidates.test.ts for the .local assumption. It holds that a channel whose only working path follows two unresolvable .local candidates opens within 5 s of the description, in both roles; it does not time the lookup itself, so re-time one addIceCandidate of an unresolvable .local candidate by hand, and a bump that changed the 10 s goes back into the bullet above with its new value.
  • Run npm run test:unit -w apps/cli -- test/unit/connection/webrtcNegotiation.test.ts for the orderings the live suite cannot show: no candidate before the local description, the offer retry and its stop condition, and the answer's payload shape.
  • Run npm run test:unit -w apps/cli -- test/unit/connection/webrtcTurnRefreshTimer.test.ts for the refresh-timer assumption. It drives TurnProtocol against a scripted relay that grants one second, holds that a live allocation draws a refresh at five sixths of the grant, that the allocation arms one timer which the close removes from the timers Node counts as active (process.getActiveResourcesInfo()), and that no refresh arrives after the close; a bump that leaves the timer armed reddens there, and the bound goes back into the documents with it. It reaches the timer and not the process: what a held loop costs a run end to end is measured against a relay by hand.
  • Re-drive the candidate-queue assumption by hand against a browser peer if the negotiation changes. The unit test pins the order this side emits in; only a real PeerJS peer shows what it does with one that arrives early.
  • Confirm the install stays clean: werift declares no preinstall/install/postinstall script and ships no native or compiled content, so nothing compiles at install and no allowScripts verdict is needed (see The install-script policy). mediabunny (MPL-2.0) is installed as a transitive but never loaded by a datachannel-only peer.
  • TURN's relayed connectivity remains unverified by these suites. The relay transports are present in the published API, the URL parser resolves the port-443 TLS case, and an allocation has been driven against a real relay for the refresh-timer assumption above, but no exchange here runs over a relayed candidate pair; drive one before any deployment relies on relayed connectivity.

The shipped images hold no package manager ​

Both Dockerfile and Dockerfile.fips delete the base image's npm CLI and corepack shim in their runtime stage -- lib/node_modules/npm, lib/node_modules/corepack, and the bin/npm, bin/npx and bin/corepack links -- under /usr/local on the default image and under /opt/node on the variant, where the runtime comes from the official tarball rather than from a package, and the default image's runtime stage also takes out the base's bundled Yarn classic install -- the bin/yarn and bin/yarnpkg links and the /opt/yarn-v* directory -- which the tarball-installed variant never ships. The builder stages keep theirs, npm ci being what resolves the tree the runtime stage copies in, so this is a property of the shipped image and not of the build.

What it is for: the image vulnerability scan reads every language package in the image, and an npm CLI in one brings a bundled dependency tree that no pin in this repository moves. Without a package manager that scan has exactly two remedies, and the package path in the report says which. A system package moves with the base pin, by the procedure in the two sections below. A package under /app moves with package-lock.json. A finding in a tree neither reaches is one this repository cannot clear at all, which leaves exempting it or switching the gate off as the only ways past it.

What holds it: scripts/dockerfile-freeze.test.mjs freezes each image's removal command and refuses any other runtime-stage command naming npm or npx, and image_smoke.yaml asserts that none of npm, npx, corepack, yarn and yarnpkg resolves in either built image -- the half that catches a base image putting one back on a path the removal does not name. Nothing in either role needs one: both entrypoints exec node directly, and the console server spawns the CLI entry the same way (apps/web/src/jobs/cliDriver.ts).

Bumping the FIPS base image ​

Dockerfile.fips pins its Amazon Linux 2023 rootfs by multi-arch index digest, so the base moves only when someone edits that digest. Four committed values name one release between them, and a bump that moves fewer than all four builds an image whose userland and package layer came from different snapshots. What each of those pins is, and how the current values were resolved, is in CONTAINER_IMAGES.md; what follows is the procedure that keeps them together.

No mechanical arrival signal, by configuration. The docker ecosystem block in .github/dependabot.yml ignores amazonlinux, so once that entry sits on the branch Dependabot reads its configuration from (below), nothing opens a pull request when the tag is rebuilt onto a newer rootfs. The four values below have to name one release between them and the move imposes a re-measurement obligation besides, none of which a version comparison over tag components can see -- so this base moves as a coordinated, human-reviewed change or not at all, and the digest freeze in scripts/dockerfile-freeze.test.mjs is what pins it meanwhile. The default image's node:26-alpine base has no such entry, and its digest-only bumps do arrive on that block's weekly schedule; the two pins' treatments differ by design, and Bumping the default base image is the other one. Why the split was taken is in fips-variant-image.md.

Which branch that entry is read from. Dependabot resolves .github/dependabot.yml from the repository's default branch (main) rather than from the branch an update targets, so an entry merged to staging suppresses nothing until a release promotion moves it to main. Observed 2026-08-31, with the amazonlinux entry on staging and main's copy of the file with none: a docker-ecosystem run triggered by hand showed "ignore-conditions": [] in its update-job definition and filed a bump of this base's digest against staging (PR #1166). The same default-branch evaluation governs Dependabot's security alerts, which RELEASES.md records for triage and whose pull requests against main it handles in A security update opened against main. The entry is on main. What shows it in force is a docker-ecosystem run whose update-job definition has an ignore-conditions entry naming amazonlinux instead of an empty list. While the list reads empty the entry is inert and an amazonlinux pull request that arrives is closed unmerged -- what the entry removes is a filing, and the digest freeze plus the procedure below is what holds the base either way.

The block ignores two names for this base: amazonlinux, Docker Hub's name for the image, and public.ecr.aws/amazonlinux/amazonlinux, the ECR Public name Dockerfile.fips spells. Which of the two Dependabot matches against the ECR Public reference has not been observed on main. A pull request that arrives for it is closed unmerged, as above.

What shows a base worth moving to, in the absence of a filed bump:

  • The Trivy scan the release workflow runs over the built variant, which gates every publish, so a snapshot with fixable high or critical findings blocks a release rather than shipping. image_smoke.yaml's Trivy scan of the variant asks the same question on every trigger, pull requests included, and gates on it the same way (below).
  • An advisory, or a certificate-lifecycle event, read by hand against the pins in CONTAINER_IMAGES.md.

The same-diff quintet. A base move lands with all five of these or not at all; the first four are verified against one release:

  1. The digest in Dockerfile.fips's FROM, resolved as the multi-arch index digest -- docker buildx imagetools inspect public.ecr.aws/amazonlinux/amazonlinux:2023 -- because a platform-specific manifest digest names one architecture and fails on the other. The digest is ECR Public's: Docker Hub's amazonlinux:2023 publishes the same releases under different digests, so the digest is resolved on the registry the FROM names. Why the base comes from ECR Public is in fips-variant-image.md.
  2. The FIPS_BASE literal in scripts/dockerfile-freeze.test.mjs. npm run test:scripts stays red until it matches, so the reconciliation is forced rather than remembered.
  3. ARG AL2023_RELEASEVER in Dockerfile.fips, the snapshot every dnf transaction resolves against. The nodebase stage compares it against the system-release version the rootfs reports and fails the build when the two differ, so a digest that moves without it reddens the image build.
  4. The certified provider's pins -- FIPS_PROVIDER_PACKAGE, FIPS_PROVIDER_VERSION and FIPS_MODULE_VERSION -- confirmed against what the new snapshot serves rather than copied over. Moving the releasever moves what the package layer resolves: a snapshot no longer serving that NVR fails the dnf swap itself, and one serving a different module under the same package name fails the rpm -qf and openssl list assertions that follow. A snapshot still serving the pinned NVR leaves all three values unchanged -- a result those assertions establish, not one the diff may assume.
  5. The # base pin: header line in NOTICE-os-packages-fips.tsv, the OS-layer attribution list described in CONTAINER_IMAGES.md. scripts/generate-os-package-attribution.mjs --check compares that line against the digest Dockerfile.fips names, so image_smoke.yaml's drift leg fails the pull request until the list is regenerated from an image built on the new base; npm run test:scripts reddens on the same mismatch without a Docker daemon. The regeneration takes new rows, not an edited header line, and it states no comparison between architectures unless the run is given --architectures-compared <YYYY-MM-DD> -- a day both architectures were built without a layer cache, queried, and found to agree. The architecture split below is why that comparison is hand work rather than something the pull request's own run re-takes.

Read the release out of the new rootfs to learn what to write in (3): rpm -q --qf '%{VERSION}' system-release inside it, or docker create plus docker cp of /etc/os-release to read the other architecture's rootfs without emulating or executing it.

The Node runtime moves in the same diff. Move ARG NODE_VERSION in Dockerfile.fips to the current Node 26 release, together with both per-architecture tarball hashes and the matching literals in scripts/dockerfile-freeze.test.mjs and CONTAINER_IMAGES.md. Take each hash from that release's SHASUMS256.txt and confirm it by running sha256sum over the downloaded tarball. No Dependabot entry tracks the tarball, so a Node security release between base bumps is caught only by a dependency survey.

The re-measurement obligation. The variant's published FIPS claims rest on measurements of one base rootfs and one provider build. Those taken by hand are archived outside this repository, and the notes that state them (fips-variant-image.md, fips-provider-surface.md) cite those archives rather than any path in this tree -- so nothing here re-derives them and no check reddens when a base move leaves one stale. The engagement legs are the automatic half, re-taken by the pull request's own run (below); these are the hand work a move obliges beyond it:

  • The OS package closure figures -- package count, image size, and the GPL-3.0/LGPL-3.0 breadth recorded in CONTAINER_IMAGES.md. These are properties of the snapshot rather than of the module, and a package gained or dropped moves them. The setuid/setgid half of the closure is the one part a check already re-takes.
  • The certificate comparison behind the provider choice, whenever (4) above lands on a different provider NVR: the choice between the certificates available under that package name rests on a side-by-side vulnerability scan of two images, which is a measurement of the package layers being compared.
  • The architecture split. The hand measurements were taken on aarch64 and CI builds amd64, so a move inherits that split rather than closing it; a figure re-taken on one architecture is labelled with it.

What re-proves the result. image_smoke.yaml builds Dockerfile.fips on every pull request touching it, which is where all of those build-time assertions run. It follows the build with the variant's entrypoint report, the engagement probe, an end-to-end two-party exchange between two containers of the built image, and the setuid/setgid inventory recorded in CONTAINER_IMAGES.md. A new base whose package closure gains or loses one of those bits reddens that step, and the inventory is re-recorded in the same diff.

That workflow's Trivy scan gates on the variant leg on every trigger, the pull-request path included, so a bump taken in order to clear a scan finding is confirmed by the pull request's own run rather than by a dispatch beside it. That posture holds only while the pinned base and snapshot name a rootfs whose closure scans clean, and a check red on every product PR for weeks teaches reviewers to ignore it. A base carrying a fixable finding no published rootfs can reach -- the fix served by a newer package snapshot than any rootfs the registry has been rebuilt onto, which the build's release assertion refuses to mix -- is answered by an entry in .github/trivyignore.yaml scoped by purls to the packages assessed, stating why the finding is accepted (the vulnerable path must be one no Alcove role reaches), naming the base bump that clears it, and carrying an expired_at that forces the re-read. The release workflow's gate reads that same file, so the entry is a publish decision as much as a merge one; that is why it is scoped and dated and never an id-only entry, which would also suppress the finding on the other image. Taking the leg back off the pull-request path is not the alternative: it hides the scan from the runs that would show the finding cleared. The entry comes out in the same diff as the bump. Either way the findings arrive as code-scanning alerts under container-image-fips, and scanning the built variant by hand (OS and language packages, vuln scanner, high and above, fixable only, against .github/trivyignore.yaml) answers the same question off CI.

One limit sits between a green run there and a bump being proved:

  • The build is single-arch. That workflow builds native amd64 with no QEMU, so on a pull request the release assertion speaks for the amd64 rootfs at the new digest and not for the arm64 one. The release workflow's multi-arch push builds this image for both architectures, so the assertion runs for arm64 first at release time; reading the other architecture's release by hand, as above, is what covers it before then.

Bumping the default base image ​

This is the second Dockerfile base-image pin with its own bump section outside the machine-read Upgrading ... heading form, per the convention recorded in this document's lead alongside the FIPS base image's section. A heading in that form is read only as a parenthesised list of npm package names, so npm run check:dependabot-pin-coverage has no way to hold either docker pin's bump procedure to a Dependabot config entry. It is ecosystem-blind by construction, not merely unextended to this pin yet. Extending it to a heading form tagged by ecosystem is a separate call from this section, which exists to make the procedure below readable rather than to close that gap.

Dockerfile pins node:26-alpine's multi-arch index digest, in both stages, so the base moves only when someone edits that digest -- what the freeze covers and why the base is pinned by digest rather than by tag is in The Docker image's dependency freeze.

The arrival signal. Dependabot's docker ecosystem (.github/dependabot.yml, weekly on Monday, targeting staging) tracks the digest in the FROM and opens a pull request of its own when the node:26-alpine tag is rebuilt onto a newer image. That block declares no groups, so the bump is never batched. This is the base image that block covers: the FIPS variant's is held out of it by an ignore entry, and moves by hand instead. Its weekly cadence is the accepted latency here -- nothing polls the registry more often, and a rebuild is what this waits for rather than something this repository can provoke. Dependabot's own commit edits only the Dockerfile FROM lines; nothing updates the mirrored literal below, so the pull request arrives with npm run test:scripts red by construction -- the freeze test's whole point, not a defect in the pull request.

The same-diff trio. A digest bump lands with all three of these or not at all:

  1. The digest in both of Dockerfile's FROM node:26-alpine@sha256:... lines. This is what the Dependabot pull request edits, and the only one of the three it moves on its own.
  2. The DEFAULT_BASE literal in scripts/dockerfile-freeze.test.mjs. npm run test:scripts stays red until it matches, so push the reconciling edit onto the same pull request as a second commit on the Dependabot branch -- how the one prior default-base bump landed (the node:26-alpine bump merged as PR #865) -- rather than merging a red check. How Dependabot's own next push treats a manually edited branch has not been driven here; if the branch is recreated, re-push the reconciling commit with it.
  3. The # base pin: header line in NOTICE-os-packages-default.tsv, the OS-layer attribution list described in CONTAINER_IMAGES.md. scripts/generate-os-package-attribution.mjs --check compares that line against the digest Dockerfile names, so image_smoke.yaml's drift leg fails the pull request until the list is regenerated from an image built on the new base; npm run test:scripts reddens on the same mismatch without a Docker daemon. The regeneration takes new rows, not an edited header line: a new base is a new package layer. It states no comparison between architectures unless the run is given --architectures-compared <YYYY-MM-DD>, which names a day both architectures were built without a layer cache, queried, and found to agree -- a comparison the new base requires again, and one the generator states for no other run.

What re-proves the result. image_smoke.yaml's pull-request path filter lists Dockerfile, so the pull request runs the full matrix: both the default and the FIPS legs rebuild, even though only the default base moved. The default leg's Trivy scan (vuln, OS and language packages, high/CRITICAL, fixable findings only, against .github/trivyignore.yaml) runs on this pull request as it does on every other -- a new snapshot with a fixable finding fails the pull request rather than shipping. The same job also drives the assertions the freeze test cannot reach, because they read a running container rather than the Dockerfile text. Those are the account the image runs as and its writable set and symlink containment, the headless CLI invocation, a real file-drop exchange between two containers of the built image, and scripts/assert-image-capabilities.mjs's walk of what the shipped setup scripts ask of the image. The FIPS section's single-arch limit applies unchanged here: the smoke build is amd64-only with no QEMU across the whole job matrix, and the release path's Trivy scan reads an amd64-only build before the separate multi-arch push, so neither workflow exercises the arm64 image a bumped digest also includes.

A package the mirror fixed, without a digest bump. The runtime stage's apk add --no-cache samba-client floats against the Alpine mirror (why, and what that costs: CONTAINER_IMAGES.md), but a build replays that layer from image_smoke.yaml's layer cache for as long as nothing above it changes. The cache scope carries the ISO week, so the scheduled Monday run on the default branch rebuilds the layer from the mirror and a fix the mirror already carries reaches the scanned image within a week with nothing in this repository changed. A scan that goes red on a package whose fixed version the mirror already holds is reading a layer cached earlier that week, not a reason to bump the digest. A re-run does not clear it, since it reads the same week's scope; wait for the next Monday run, or delete that week's cache entries (gh cache list, gh cache delete) to have the next run build from the mirror.

What beyond the digest deserves a look. node:26-alpine is a floating tag underneath its digest, so a rebuild the tag picked up can have a different Node minor or patch release, and a different bundled npm, than the digest it replaces -- and nothing in image_smoke.yaml prints or asserts either version. Read them before merging, the same way the smoke job reads a container's shell: docker run --rm --entrypoint sh node:26-alpine@sha256:<new-digest> -c 'node --version && npm --version'. The npm version is the one to read closely. The npm floor the allowScripts install-script policy needs is a property of exactly this digest, which is why that section states a measured bundled-npm value for the digest pinned at the time it was written rather than deriving it from anything checked automatically. Nothing re-measures it when the digest moves, so a bump that changes it leaves that section's stated value stale until someone updates it in the same diff. A digest that moves the bundled npm below 11.16.0 would leave the policy silently off, and below 11.17.0 falls under the floor that section states as the builder's intent; either is worth a line in the pull request, and updating the stated value either way keeps that section accurate, even though nothing here turns red over either.