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) drivesssh2internals past the publicssh2-sftp-clientAPI, so both packages are exact-pinned inapps/cli/package.json. Every bump -- including a security patch -- is then a deliberate edit rather than annpm audit fixthat 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 thepeerjs-js-binarypackwire format directly (packages/core/src/connection/binaryPackBounds.ts). Both are exact-pinned inapps/web/package.json--peerjs-js-binarypackdeclared directly, not left a floating transitive, precisely because the bound parses its wire format -- and pulled out of the routine Dependabot batch into a reviewedwebrtc-stackgroup (.github/dependabot.yml).packages/coredeclares 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'sRTCPeerConnectiondirectly 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 inapps/cli/package.json, and pulled out of the routine Dependabot batch into the same reviewedwebrtc-stackgroup (.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 localfile: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:
| Field | Meaning |
|---|---|
attestation_expected | false -> report and pass, leaving the sidecar as the whole control; true -> verify, and fail on any non-zero verifier exit |
artifact | The tarball's filename, which must be the one vendored in lib/ |
sha256 | The 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_ref | The producing repository's git ref. Required once armed |
source_digest | The 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:
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.tssizes 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'sexportscarries adefaultcondition and Node 26 loads an ES module throughrequire. It returns the module namespace (__esModuleanddefaultkeys), not the function.- With
canonicalizeheld external, the CJS bundle loads, and the firstcanonicalStringcall fails withcanonicalize 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
ESTRICTALLOWSCRIPTSwith no verdict and with an"[email protected]": trueverdict 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=esbuildand[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
ESTRICTALLOWSCRIPTSnaming[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
falseverdict denies runs, with no diagnostic about the map, and the flag draws onlynpm warn Unknown project config "strict-allow-scripts"(and, from the committed.npmrcon 11.15.0, the same warning fordangerously-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 theesbuildverdict 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 leadingvand build metadata are accepted and match, while a disjunction holding=1.0.0or 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 abinwould lose its.binlinks 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).fseventsis denied at the one installed version ("[email protected]", at the root, a darwin-only optional dependency ofrollup,tsxandvite). Nothing runs either way: the published tarball declares onlyclean,build,testandprepublishOnlyand ships nobinding.gyp, so thenode-gyp rebuildan install would run does not exist -- it sits inbuild. Re-driven 2026-10-01:- The tarball, unpacked from
npm pack, holds exactly that script set and nobinding.gyp. - The lockfile's
hasInstallScript: truematches the flag the registry's abbreviated packument carries, while the registry's full version metadata (whatnpm view [email protected] scriptsreads) lists aninstall: node-gyp rebuildthe 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 foresbuildundernpm 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=arm64the 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-scriptsrefuses on a platform that installs it, whether or not a script exists to run.- The tarball, unpacked from
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/watcherthat[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 av-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 thebrace-expansionunder[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/reposhorthand 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 thecore-jsunder[email protected]overridden, a transitive dependency overridden to afile:tarball, or to a remote tarball URL spelled in the registry's own<name>/-/<name>-<version>.tgzshape, is reported uncovered under a name-keyedtrueand a name-keyedfalsealike, exactly as with no verdict at all. The lockfile entry npm writes has that URL inresolved, 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 installESTRICTALLOWSCRIPTSnaming 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, annpm:alias and a$namereference 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
overridesblock 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 ciinstalls that same lockfile and what runs is what the check read -- measured 2026-10-01 on 11.17.0 and 11.19.1, abrace-expansionoverride standing over a lockfile resolved without it installs clean under the committed map. Where the lockfile lacks a package the unapplied override needs,npm cirefuses rather than resolving one: on both releases that day, an override of"brace-expansion": "^1.1.11"over the committed lockfile failsEUSAGE(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.gypand declares no install script gets a syntheticinstall: node-gyp rebuildthat npm discovers only after extraction. Its lockfile entry has nohasInstallScriptflag, 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-scriptsexactly as without it. Re-driven 2026-10-01 on 11.19.1 for afile:tarball only: one holding abinding.gypand no scripts had itsnode-gyp rebuildrun under the flag, where the same tarball declaring aninstallscript is refused. A registry package of that shape was not driven. - A stale
node_modulesholds packages the lockfile no longer installs, and npm's advisory reports those:npm approve-scripts --allow-scripts-pending(from 11.18.0 alsonpm install-scripts ls) can list packages the check is silent about -- on 11.19.1 on 2026-10-01, acore-jsdropped from the manifest and the lockfile but left innode_modulesis 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
falseverdict 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/webexits 0 at 131 components, and the same command without-w apps/webexits 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-onlyexits 0, so the tree reports no peer conflict.yaml2.9.1 andtslib2.8.1 are installed in the shipped tree, pernpm 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 holdstslib1.14.1, a copy nested undertsyringe.
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/viteat 8.1.4 and nests the bumped range's 8.3.2 underapps/webwith 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 rootplaywright1.64.0, nestsplaywrightandplaywright-core1.64.1 underapps/web, andscripts/check-nested-root-package.mjsfails naming both. - Deleting those two
apps/web/node_moduleslockfile entries and runningnpm install --package-lock-onlyhoists 1.64.1 to the root and the check passes. - Raising only the range in
apps/web/package.jsonand runningnpm install --package-lock-onlyon 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-onlyanswersfound 0 vulnerabilities.npm run site:buildcompletes, 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 thosevite,esbuildand@esbuild/*entries, thennpm install --package-lock-only. [email protected]declares an install script, and theallowScriptsmap denies it as it denied 0.21.5; the site build above runs with that script blocked.npm run site:devserves 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 theSFTPWrapperbefore 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
readdiras anErrorwhosecodeequalsSTATUS_CODE.EOF(numeric1), not as an empty success batch -- the EOF contractlist()'s batch loop terminates on. Each call answers one server batch, and a call that errs passes no list; all three are driven inapps/cli/test/integration/sftpStackPremises.test.ts. STATUS_CODE.EOF === 1andOPEN_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 internallib/protocol/SFTP.js, not from its package entry point, and@types/ssh2types them only as a compile-timesftpnamespace). These are fixed SFTPv3 wire-protocol values, so they are extremely unlikely to renumber, but confirm them againstSTATUS_CODE/OPEN_MODEin 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, andcreateExclusivemaps it to anexists()-disambiguatedEEXIST. The assumption is that ssh2-sftp-client reports a serverFAILUREon its high-levelrenameas numericerr.code === 4-- it passes ssh2's raw status throughfmtErrorontoerr.code, the same4createExclusivereads from the rawopencallback. If a future version remapsrename'serr.codeto a non-numeric string (e.g.ERR_GENERIC_CLIENT) or renumbersFAILURE, the retry silently stops firing -- the transient-rename flake returns, with no correctness break; thessh2SftpAdapterunit tests pin the numeric-4behavior.- ssh2-sftp-client stores the raw wrapper on
this.sftp, assigned in its'ready'handler on eachconnect()and otherwise only cleared -- the library performs no auto-reconnect of its own. Two adapter behaviors rest on this. (1) The captured-wrapper reads inlist()/createExclusive()(const { sftp } = ...at method entry) assume nothing swapsthis.sftpunder 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.sftpis assigned only from aconnect()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 alist()/createExclusive()capture was live, identically withephemeralSessionstrue 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 supersededSFTPWrapperneither 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.tscaptures 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-opendirbranch issuessftp.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 nativesshd. (2) Recovery re-dials by callingconnect()again and does not call the library'send()first. That rests on ssh2-sftp-client clearingthis.sftpon a clean drop, on the forced close below clearing it where a drop did not (soconnect()'s "already connected" guard cannot fire either way), and onend()latching a permanentendCalledflag that disables the global'close'listener'sthis.sftpclear. That is why calling it would break detection of a later idle drop. If a future version stopped clearingthis.sftpon a clean close/end, or resetendCalledonconnect(), re-verify the recovery re-dial path. - The connection-per-poll (ephemeral-session) mode's idle-boundary release (
releaseForIdle, gated on the internalephemeralSessionsoption; 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 ssh2Client's ownend()(reached viathis.client, the same boundarysetNoDelayuses -- not ssh2-sftp-client'send(), which would latchendCalledand permanently disable the global'close'listener the mode depends on) and then awaits theClient's'close'event to know the teardown finished before the poll loop idles. The critical assumption is thatClient.end()emits'close', not merely'end':releaseForIdleawaits exactly'close'(viaClient.once('close', ...), bounded by a localCLIENT_CLOSE_TIMEOUT_MSsafety check so a withheld close cannot hang the loop). It is that'close'-- not'end'-- that fires ssh2-sftp-client's constructorglobalListenerto clearthis.sftp, leaving the adapter in the exact cleared-session state a server drop produces, ready for the next cycle'sensureConnectedto 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 theClientemits'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.sftpis still set,_sock.writableEndedis true andreadableEndedfalse). 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 modeconnect()verifies that theClientstill exposesend,once, andremoveListener, 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 owndestroy()on the socket the bullet above names (this.client._sock.destroy), whichconnect()verifies in this mode alongside the boundaries above. On the branch where the release's ownend()ended the transport but no'close'arrived within the bound, the release destroys that socket and awaits theClient's'close'under a second, shorter bound (FORCED_CLOSE_TIMEOUT_MS), then checks thatthis.sftpactually cleared. That is the one assumption hereconnect()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 withdestroy()(it still reads set in the same tick), theClient's'close'follows in single-digit milliseconds,this.sftpis then cleared, the nextensureConnecteddials, 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_sockis still anet.Socketexposingdestroy()and that destroying it still drives theClient'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 onephemeralSessions. 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'send()drives the ssh2Client'send()(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 withephemeralSessionsfalse and at 20 s with it true, against 3 ms with a server that closes. (2) Destroying the socket beneath a pendingend()resolves that promise rather than rejecting it (measured: resolved 2 ms afterdestroy(), with_sock.destroyedalreadytruesynchronously 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) Anend()that rejects closed nothing: ssh2-sftp-client raises that rejection from the temporary'error'listener it installs for the call, whoseendListener/closeListenerare gated off byendCalled, 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.destroyedas the read-back -- the one assumptionconnect()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()resolvesclient._sock.destroy, withClient.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 --endand_sock.writableEnded-- which it never drives. The terminal close still resolves_sock.destroyagain 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 theend/once/removeListener/writableEndedthe 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, orClient.removeListenerfails 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
ClientreadswritableEnded: true, readableEnded: false, destroyed: false, andthis.sftpis still set -- identical withephemeralSessionstrue 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/removeListenerfor the'close',_sock.destroy, and Node's_sock.writableEnded/readableEndedas 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/removeListenerand_sock.destroyare 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 leavesthis.sftpset over an ended_sock, and that destroying that socket still drives theClient's'close'and the property clear;apps/cli/test/integration/heldSessionWithheldClose.test.tsdrives it end to end anddialDeferral.test.tscensuses 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 nativesshd. - 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 ssh2Client'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 theClient's'end'and its'close'--get,put,delete,rename,existsand the library's ownlist, each with anUnexpected end eventmessage -- withthis.sftpalready cleared at that'end'. The rawreaddirthe adapter'slist/createExclusivedrive instead rejects withNo response from serverafter 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 chunkedputsettles through its bounded source, a hop past the library's rejection. The socket does not separate the two -- at the high-level rejection it readswritable: 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 theClient's own'end'/'close'pair, one persistent listener pair per adapter on the sameClient.onboundary 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. AClientexposing noon()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 unwatchedClientas 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 mockedClientinapps/cli/test/unit/connection/ssh2SftpAdapter.test.tson the two shapes the relocation can take. Withon()gone andonce()still in place, aget/put/existstorn in flight still recovers on one dial (this.sftpcleared,_sock.destroydriving 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 fullFORCED_CLOSE_TIMEOUT_MS, since a'close'listener armed after the event never resolves early. With the wholeEventEmittersurface gone -- Node'sonce()is itself implemented in terms ofon(), so aClientthat 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 theERR_NOT_CONNECTEDit 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)()readsinstanceof EventEmittertrue on[email protected], withon,once, andremoveListenerall functions. Re-verify on a bump that a clean server-side drop under a high-level operation still settles it from theClient's'end', ahead of the'close'and withthis.sftpalready cleared, that destroying the socket still drives that'close', and that theClientstill exposes theon()the reading is taken through.apps/cli/test/integration/inflightDropRecovery.test.tsdrives the operation classes and a sweep of cut positions inside one transfer.inflightDropExchange.test.tsholds a two-party exchange cut mid-message to a two-sided contract -- delivering the message or rejecting the sender'ssend(), 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.tsholds a cut that tears several operations at once to settling every one of them on the tear, anddialDeferral.test.tscensuses 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 nativesshd, and the measured shapes aressh2.Connection.end()at a counted opcode and the sameend()from inside the RENAME handler, on either side of that request'sfs.rename-- not the whole class of clean drops. - ssh2's
Client.connect()issued on a socket that is still writable defers the attempt behindonce('close', ...)and arms noreadyTimeoutfor the deferred attempt, so Alcove'sserverConnectTimeoutMs-derived bound silently does not apply to it. Measured on the pinned versions, a secondconnect()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.tsdrives 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 iswritable, 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'sif (sftp) return true, andshouldRecoverFromSessionLoss, 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
readyTimeoutcovers a dial only as far as authentication: ssh2 clears it in itsUSERAUTH_SUCCESShandling, and thesubsystem sftprequest 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 ssh2Client's'ready'-- the same event ssh2-sftp-client waits for before requesting the subsystem, and the one it exposes asthis.clientfrom its constructor, so the subscription is in place before the dial -- and arms the sameserverConnectTimeoutMswindow over what follows (see TRANSPORT_LIVENESS.md). Both halves are checks rather than prose, inapps/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 ownreadyTimeout, 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 theClient'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.tsholds 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 aClientthat no longer exposesonceorremoveListeneris refused before it dials, and a first dial that succeeds on one whose socket no longer exposesdestroyis 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 sameClientbefore 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'sclosedanddestroyedflags synchronously insidedestroy(), 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 theClient'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 nodestroy()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 refusedconnect()leaves in place, is left alone. Each assumption is a check inapps/cli/test/integration/sftpStackPremises.test.ts: a dial issued before that'close'fails, a destroyed socket readsclosedbefore theClient's'close'fires, a dial that fails on its handshake timeout or on a reset connection rejects over a socket alreadyclosedwith that'close'still to come, and a dial issued after that'close'connects.apps/cli/test/integration/sftpKexRedialPolicyFlip.test.tsdrives 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.readableEndedmarks 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 leavesthis.sftpset, so without that flag a release landing in the window between the two would drive a no-opend(), see the'close'promptly, and read a server-side drop as a release of its own.writableEndedis read at two points and answers a different question at each. Before the release drives its ownend(), awritableEndedtransport is one something other than this release ended -- ssh2 answering a partner'sSSH_MSG_DISCONNECTis the shape it is read for, alongside the withheld-close drop the recovery bullet above measures. After thatend(), the same flag is the transport-is-ended assumption above, kept as a runtime check rather than a claim:trueis 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.falseis anend()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,reconnectsand 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.tsdrives it. Both flags are plainnet.Socketproperties rather than ssh2 internals, so only the_sockboundary itself is at risk on a bump, andconnect()verifies in this mode that_sockreportswritableEnded-- 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 twoend()callers on the adapter at once, and its memoized terminal close is what holds them to one clientend(). ssh2's own idempotence sits behind that memo as a safety check rather than as the guarantee: it guardsClient.end()'s body withisWritable(this._sock), so a second call is a no-op that raises nothing, and ssh2-sftp-client'send()is likewise idempotent via itsendCalledlatch. Confirm both on a bump -- a version that dropped theisWritableguard or made a secondClient.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'send()and destroysthis.client._sockwhile 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 inapps/cli/test/integration/sftpStackPremises.test.tsdriven at thessh2-sftp-clientlayer -- 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'send()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 neverend(), and why it cannot reuse the terminal close's forced branch (that one waits out theend()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: anend()driven mid-handshake ahead of the destroy also disables it, leaving the parkedconnect()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.destroyedreadstruesynchronously on return fromdestroy(). The parkedconnect()rejects 1-5 ms later across five samples, and with no retry budget left theTCPSocketWraphandle 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 plainErrorwithcode: "ERR_GENERIC_CLIENT"and messagegetConnection: 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 asgetConnection: 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 resolvedend()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-attemptreadyTimeoutassumption 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
Clientasthis.client, and ssh2'sClient.setNoDelay(true)togglesTCP_NODELAYon 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 theClientor dropssetNoDelaymakes the adapter log a warning and continue with Nagle enabled (slower, still correct) rather than fail. - The ssh2
Clientexposes its underlyingnet.Socketonthis.client._sock, and ssh2 exposessetNoDelaybut notsetKeepAlive, soconnect()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). LikesetNoDelaythis has no correctness weight: the call is guarded and non-fatal, so a version that renames or relocates_sock, or one where_sockis not anet.SocketexposingsetKeepAlive, 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), coveringgetagainstget/put/delete/rename/exists,listagainstget/list/createExclusive,createExclusive+exists,put+delete,rename+exists,safeDelete+get, and the heartbeat's ownrealPath(".")beat againstget,list,createExclusiveand 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, acode: 2on re-deleting and anEEXISTon 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 failingget(ENOENT),createExclusive(EEXIST) ordelete(ENOENT) alongside alistparked 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 becauseremoveTempListenersremoves by identity, so the survivor's own listeners stay installed. What overlapping operations do cross is the session-loss rejection:addTempListenersprepends its per-operation listeners, so the most recently issued high-level operation's listener runs first and consumes the ssh2Client's'end', and the earlier operation's listener is then suppressed by the library'seventHandledbookkeeping 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 -- betweenexistsandget, where it reversed which of the two failed, and between the beat and agetalike. 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 thataddTempListenersstill prepends and its_resetEventFlags/eventHandledbookkeeping still suppresses the earlier listener once a later one has consumed the event (the ordering the crossing rests on), and thatremoveTempListenersstill 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-levelgetof 8 MiB, in both issue orders and at five cut positions inside the pair, 50 runs. Also driven: forty-wayPromise.allSettleddeleteandPromise.allsafeDeletefan-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 thedeletefan-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-levelgetis settled from theClient's'end'and enters recovery first in both orders, and the rawlistfrom 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 withrealPath: Unexpected close event, never a stall. A beat is parked on the wire through the in-process server'sREALPATHwithhold, the sameinject.withholdOnhook its other served opcodes use.apps/cli/test/integration/concurrentOpDropRecovery.test.tsholds 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 therename()re-issue's existence probe against anything; and at a session death specifically, nothing was driven forgetagainstput/delete/rename, forlist+list,put+deleteorrename+exists, or forkeepaliveagainstcreateExclusiveor anotherkeepalive. Core's own concurrency -- asend()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 everysend()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 nativesshd, and the withheld-close class cannot be. - What the same per-operation listeners cost is measured on the same assumption, because concurrency on one
Clientis what makes them stack.addTempListenersattaches exactly one'end', one'close'and one'error'listener per in-flight operation, andremoveTempListenerstakes all three off when it settles. So a fan of widthwpeaks atw + 2on'end'and'close'andw + 1on'error'above the persistent 2/2/1 a connected client holds (ssh2-sftp-client's three constructorglobalListeners, plus the adapter's own'end'/'close'transport-lifecycle watch). Node's default ceiling is 10 (events.defaultMaxListeners, whichclient.getMaxListeners()reads back), so a concurrent fan of 8 is silent, 9 emits twoMaxListenersExceededWarnings ('end','close') and 10 or more emits three -- identical in the default held-session mode and underconnection_per_poll. Node raises that warning throughprocess.emitWarningand never throughconsole, 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 norwithCapturedLogscan observe it; an in-processprocess.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
Clientat construction toSHARED_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 is2 * MAX_DIRECTORY_ENTRIES(16384) for core's rendezvous entry sweep, plusMAX_DEFERRED_CLEANUP_DELETES(64) for theconnection_per_pollcleanup 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:sweepProtocolFilesmerges 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 singlePromise.allSettled. Under a split inbound/outbound scope those are two listings, each refused separately atMAX_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 aPromise.allofsafeDeleteover a subset of one guarded listing and so at mostMAX_DIRECTORY_ENTRIES, half the term. Each is awaited to completion before the next is issued and beforesweepProtocolFiles, 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 isnis silent atnlisteners and warns on then + 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. The3is 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 itsconnection_per_pollvariant 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'send()parks while it waits the close out, its own close handler plus the per-operation'close'bracket theend()call itself takes. What the term is meant to cover is that teardown pair together with the heartbeat'srealPath(".")beat, the poll loop's own operation, and asend()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'sPromise.allover this party's unconsumedresponsibleFiles-- 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.tspins 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 aconnection_per_pollrelease 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 the3rests 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 uncappedresponsibleFilescleanup 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 nativesshd. - 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_requestsby the timedoFatalSFTPErrorruns. TheNAMEandDATAresponse handlers delete it unconditionally before the parse/check that callsdoFatalSFTPError, and theHANDLEhandler deletes it inside its malformed branch (on a defined request id) immediately before that call. All three leave nothing forcleanupRequeststo fail. If a future ssh2 instead deleted after the fatal path (or stopped deleting in theHANDLEmalformed branch),cleanupRequestswould 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; thelivenessfault-injection unit test below proves the current ordering. - ssh2-sftp-client's
put(src, dest)pipes a non-Buffersrcinto the write stream (_put's else-branch,rdr.pipe(wtr)), and that write stream consumes under ack-driven backpressure -- ssh2'sWriteStream._writecalls its stream callback (releasing the next pull) only after the server acknowledges the write. Theputliveness idle window rests on both: the adapter handsputa Readable that streams the payload inSFTP_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'sSFTP.writechaining a buffer larger than_maxWriteLeninto 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 rawSFTPWrapper), so it adds no new internal coupling beyond these behavioral assumptions. Thessh2SftpAdapterunit 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 inapps/cli/test/integration/sftpStackPremises.test.ts, alongside two assumptions the chunked source's own teardown rests on: a failedputleaves 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 theput, only on the source's'error', so the stall destroys the source with an error rather than bare. - A host-key verification rejection (
hostVerifiercallingverify(false), from eitheropen()verifier in core'sfileSyncConnection-- the pinned-key enforce path on a mismatch, or the no-pin fail-closed default) is reported as anErrorwhose message contains the fragmentHost denied, with no machine-readablecodeset. 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'sisFatalDialErrormatches 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'skex.jsasHost denied (verification failed)), both consumers stop firing.connect()retries a host-key failuremaxReconnectAttemptstimes before failing with the same outcome (slower and noisier, no security regression, since core'sfileSyncConnectionre-wraps the same rejection into itsSFTP host-key verification failed:message regardless). AndisFatalDialErrormisclassifies 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'sprobeHostKeyFingerprint) and thesettleVerifyguard around every verifier verdict rest on four ssh2 internal behaviors, all security-relevant. (1) ssh2 invokes thehostVerifierat host-key verification and reaches userauth -- credential transmission -- only afterverify(true), soverify(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) Returningundefinedfrom the verifier (ourvoidasync callback) parks the handshake pending the async verdict. (3) ssh2'sreadyTimeoutstays armed across that park, so the probe's use of the raw (unbounded) transport is still time-bounded. (4) A lateverify(), delivered after the handshake has torn down, returns without throwing, whether areadyTimeout, a destroyed socket or an earlierverify(false)tore it down;settleVerifyswallows 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 despiteverify(false), or stopped armingreadyTimeoutuntil after verification, re-evaluate: each is a security regression (credentials to an unverified host, or an unbounded probe). Also notekeyBlobis a Buffer slice view with a meaningfulbyteOffset/byteLength(ssh2'sbufferSlice);hostKeyBlobpasses those through, because a barenew Uint8Array(keyBlob.buffer)would hash the whole pooled buffer and compute the wrong fingerprint. Assumption (4) and the slice view are driven inapps/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
netlistener answering with an HTTP error page, with a TLS alert record, and with a clean close having sent no bytes: ssh2'sdebugcallback -- the one channel that could send them -- readsSocket connectedthenSocket endedfor all three. All three reject identically withConnection lost before handshake(ssh2 setslevel: "protocol"on it, which does not survive ssh2-sftp-client'sgetConnection:wrapper). ssh2 does report pre-identification lines on itsgreetingevent, but only for lines followed by a validSSH-...identification string, so a peer that never identifies itself emits nothing there either. A peer that resets at accept instead rejects withconnect ECONNRESET <host>:<port>, andcode: "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.tsdials the pinned client at three barenetpeers -- one answering an HTTP error page, one closing having sent nothing, one resetting at accept withresetAndDestroy(an explicit RST: a plaindestroy()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 -- ondebug, ongreeting, 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 omitsportfrom 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
debugconnect option, as plain strings, one call per rendered line: ssh2's handshake and packet lines, and ssh2-sftp-client'sCLIENT[...]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, andHandshake 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 throughinspectand 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\\x1Bon the identification line and\x1bon 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 inapps/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-vcount. 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 ssh2Client'scloseevent -- which is what the adapter's terminal close waits for, so they reach the operator's--log-filebefore 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 thecloseevent -- 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 anSSH_MSG_DISCONNECTheld 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-interactivemethod by emitting a'keyboard-interactive'event on theClientwith(name, instructions, lang, prompts, finish)and awaiting afinish(answers)whoseanswerscount equals thepromptscount (the SSHnum-responses == num-promptsrule, RFC 4256, enforced by the server, not by ssh2'sauthInfoRes). Core setstryKeyboard-- fromconnection.server.keyboard_interactive, only alongside apassword-- and the adapter registers one persistent listener onthis.client(attachKeyboardInteractive) that answers every prompt with that password, for a server that disables the directpasswordmethod 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 itsechoflag 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 lastconnect()was called with, so the configuration is not re-read, a@pathreference is not resolved again, and the operator is not re-prompted; only a freshconnect()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 whentryKeyboard === true, and a server info-request reaches this listener only after userauth begins -- hence only afterverify(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-promptUSERAUTH_INFO_REQUESTitself, before emitting, so the listener never sees an emptyprompts(itsprompts.mapalways yields at least one answer). (3)Client.connect()andClient.end()do not strip Client-level user listeners (onlysock.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 recreatedSFTPWrapper's'error'listener needs. (4)tryKeyboardwith no registered listener stalls the handshake toreadyTimeout(the emit is a no-op,authInfoResis never sent), so the adapter throws loud at connect if theClientno longer exposeson()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 thepasswordmethod, so a behavioral break fails it red. - ssh2-sftp-client's constructor takes the
error/end/closeevent callbacks as its second positional argument and runs them throughglobalListener, which invokes a callback only for an event the high-level client did not itself initiate (itsendCalled/*Handledguard) and performs its own handled-flag bookkeeping andthis.sftpteardown regardless of the callback body. The adapter passes explicit callbacks there to route those out-of-band ssh2-Clientevents to its project logger --errorat error level while a session is held and at trace otherwise, the benign first-use-probe /verify(false)-rejectionend/closeat trace -- instead of the library's defaultconsole.error/console.log(whose bareGlobal ... listenerlines otherwise leak past the logger and the suite's log-level controls). Theerrormessage is server-controlled (ssh2 builds it from theSSH_MSG_DISCONNECTdescription), so it is rendered throughsanitizeErrorForDisplay-- 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 aClient-levelerrorto this sink (today none can). Which of the two levels anerrortakes turns onthis.sftp: the library hands such an error over withsftpstill 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 clearedsftpfor one arriving behind a failed dial or a closed connection (measured against a real server through the adapter; both arms are held byapps/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 asread ECONNRESETonce the dial has already rejected (measured against the hardened-sshd harness'srestricted-cryptoprofile, 39 of 40 dials; a barenetclient 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 thethis.sftpclear into theclosebody), 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]andcurve25519-sha256, wins the negotiation against a server that offers them, sendsKEXECDH_INIT, and only then dies oncrypto.generateKeyPairSync('x25519').apps/cli/src/connection/sftpKexCapability.tssubtracts the unperformable algorithms from that offer, and rests on four measured properties of the pinnedssh2, all decided by reading the client's ownSSH_MSG_KEXINIToff the wire rather than from its source. (1)algorithms.kexaccepts a modifier object as well as an explicit list -- theappend/prepend/removeshape@types/ssh2declares asAlgorithmList<T>-- andremovematches aRegExpentry as well as a string, mid-string and honouring theiflag, 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)removeis applied afterappend/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 appendsext-info-cand the Terrapin strict-key-exchange marker[email protected]to whatever list is offered, andremovedoes 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 structuredlevel: "handshake"does not survive that wrapper, so the message fragment is what there is to match on, exactly as forHost 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. Atconnect()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. Inconnection_per_pollmodeensureConnected'sisFatalDialErrorterminates 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 whatconnectLockedthrew 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 outsidesftpKexCapability.tsconstructs 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 aconnection_per_pollrun 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'sSSH_MSG_DISCONNECTdescription 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 -- inconnection_per_pollmode -- 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.tsdrives all of it against the installed ssh2. Each offer shape -- no operatoralgorithms.kex, an explicit list, a modifier whoseappend/prependnames 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 realssh2.Serverrestricted tocurve25519-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 thatssh2.Serveron every leg and against a real OpenSSHsshdon therestricted-cryptonative profile -- so a bump that changed any of the four fails it red. Its limits: the fourth dial path, theconnection_per_pollcycle-start reconnect (reestablishSession), is driven inapps/cli/test/integration/sftpKexRedialPolicyFlip.test.tsrather 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 onlycurve25519-sha256ends at the first re-dial meeting that policy, with the diagnostic that names the primitive rather than theTransportOperationStalledErrorthe 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 operatoralgorithms.kexof 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 atconnectLocked, the single path to ssh2'sconnect, 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 ownSSH_MSG_KEXINITstill 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'salgorithms.kexonce, 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 nativesshdnot being driven in that mode and therestricted-cryptoprofile 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.tsstill 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 iscrypto.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', ...)andgenerateKeyPairSync('ec', {namedCurve: 'prime256v1'})all throwerror: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 nativesshdon one leg only, therestricted-cryptoprofile, whose server accepts onlycurve25519-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-processssh2.Serveralone. That leg also determines which text reacheserror.messagethere, which is a race rather than a reading. A real OpenSSH server describes its ownSSH_MSG_DISCONNECTasno matching key exchange method found--method, not thealgorithmthe 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 ownSSH_MSG_KEXINITstill 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
ssh2does 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 fromDockerfile.fipsonaarch64--OPENSSL_CONF=/etc/alcove/fips-only.cnf, the certified module it pinned when the capture was taken,3.0.8-d694bfa693b76001, mapped withcrypto.getFips()reading1and the image's own engagement probe returningENGAGED, nodev26.7.0on OpenSSL3.5.7, its startup report reading the host kernel's FIPS mode as enabled. The measurement decodes the client's ownSSH_MSG_KEXINIToff the wire from aprobe-host-keydial against a listener that reads that first packet and answers nothing. Bothencryption_*lists contain[email protected],[email protected],aes128-ctr,aes192-ctrandaes256-ctr, and not[email protected], with noconnection.provider_optionssupplied at all (probe-host-keybuilds 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 baressh2.Clientwith noalgorithms-- whose offer is those five in the same order plus[email protected]. The provider is the only difference between the two offers, and Alcove configuresalgorithms.kex(sftpKexCapability.ts) and neveralgorithms.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-ed25519absent fromserver_host_key_algorithmswhere 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. Bothmac_*lists still contain[email protected]andhmac-sha1, identical to the control's, so the MACs stay the operator'salgorithms.hmacto 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-suppliedalgorithms.cipherlist 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.tsdecodes 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), thenotFoundOKcontract 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 inapps/cli/test/integration/ephemeralSessionExchange.test.tsthat 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 innerremoveListeners()/onReady-- confirm the setup-time'error'listener is still stripped before the wrapper is handed back. ConfirmClient.end()still emits'close'(not merely'end', and without needing a separatedestroy()), the eventreleaseForIdleawaits and the one that fires ssh2-sftp-client'sglobalListenerto clearthis.sftpfor the connection-per-poll release. And confirm theClientstill 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 theClient'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 callscleanupRequests), theNAMEandDATAhandlers (still delete the in-flight request from_requestsunconditionally before the parse/check that callsdoFatalSFTPError) and theHANDLEhandler (still deletes inside its malformed branch, on a defined request id, immediately before that call). SocleanupRequestsdoes not fail a reply-to-self in-flight request in any of the three cases. Also relevant:STATUS_CODEandOPEN_MODE(still the values above), and the handle-pathreaddirEOF contract. Also relevant:WriteStream._write/SFTP.write(the stream callback still fires only after the server acks, and an over-_maxWriteLenwrite still chains into multiple packets acked at the end -- the ack-driven backpressure and no-incremental-progress assumptions theputidle window's chunking rests on).node_modules/ssh2-sftp-client/src/index.js: confirm each of the following. The wrapper is still reached viathis.sftpwith the lifecycle above. The underlying ssh2Clientis still held onthis.client(thesetNoDelay,_sock.setKeepAlive, and_sock.destroyboundaries), withthis.client._sockstill anet.Socket.realPathis still a public method (the heartbeat's no-op keepalive).addTempListenersstill prepends its per-operation listeners, and its_resetEventFlags/eventHandledbookkeeping still suppresses the earlier operation's listener once a later one has consumed the event (the ordering the concurrent-operation crossing above rests on), whileremoveTempListenersstill 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.sftpis still assigned only from aconnect()whose "already connected" guard passed (the captured-wrapper assumption, and the reason a repeatconnect()over a live session is refused rather than replacing it, held byapps/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 constructorglobalListener'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 ssh2Client'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 rejectingend()exists for).renameandputstill pass the raw numeric status throughfmtErrorontoerr.code, so a serverSSH_FX_FAILUREis reported aserr.code === 4(the assumption the rename and put retries gate on; theputstatuses are held byapps/cli/test/integration/sftpStackPremises.test.ts)._putstill pipes a non-Buffersrcinto the write stream (rdr.pipe(wtr)) rather than buffering it (the assumption theputidle window's progress signal rests on). And the constructor still accepts theerror/end/closecallbacks as its second positional argument, run throughglobalListener, which still does the handled-flag andthis.sftpbookkeeping itself (the boundary the adapter routes off the console) and still hands a live session'serrorto that callback withthis.sftpnot 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.tsdecodes the client's ownSSH_MSG_KEXINIT), so read these only to explain a failing case. Confirmalgorithms.kexstill takes theappend/prepend/removemodifier withremoveapplied last and matching aRegExp, that an empty explicit list still falls back toDEFAULT_KEX, and thatext-info-cand[email protected]are still appended outside that filtering. Also confirm that a negotiation with nothing in common still throws with a message containingno matching key exchange algorithm(the fragment with both the diagnostic andconnect()'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 containingHost denied(the fragment bothconnect()'s terminal-on-host-key-rejection retry predicate and connection-per-poll mode'sisFatalDialErrormatch, since ssh2 sets nocodeon it). Confirm thehostVerifieris still invoked at host-key verification withservice('ssh-userauth')reached only afterward viaonHandshakeComplete(the credential-non-disclosure assumption the probe rests on), and that returningundefinedfrom the verifier still parks the handshake pending the async verdict.node_modules/ssh2/lib/client.js: confirmreadyTimeoutis still armed at socket connect and not cleared while an asynchostVerifieris parked (it bounds the probe's raw, budget-unwrapped transport); confirm aconnect()issued on a still-writable socket is still deferred behindonce('close', ...)with noreadyTimeoutarmed 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 theClientwith(name, instructions, lang, prompts, finish)(thecurAuth.prompt->this.emit(...)boundary), and still auto-responds to a zero-promptUSERAUTH_INFO_REQUESTwithout emitting. Confirm too that it still offers'keyboard-interactive'only whentryKeyboard === true(reached after host-key verification), and thatClient.connect()/end()still leave Client-level user listeners in place (onlysock.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: confirmUSERAUTH_INFO_REQUESTprompts are still parsed as{ prompt, echo }objects (an array, soprompts.mapis 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
DataConnectionclass -- the onepeer.connectand an incoming connection use by default -- exposes_handleChunk(reassembling chunk slices keyed by__peerDatainto_chunkedData, deleting the entry on completion),_handleDataMessage(the sole point each frame isunpacked: an unchunked frame directly, the reassembled buffer via the completion recursion), and_chunkedData(the per-id partial store). The bound replaces_handleDataMessagewith its ownunpackand a copy of the dispatch that follows it in PeerJS: a truthy__peerDatawhosetypeis"close"callsclose(), any other truthy__peerDatagoes to_handleChunk, and anything else is emitted as adataevent. A frame decoding tonullorundefinedis emitted too, where PeerJS's own dispatch throws, matching the CLI's receive dispatch.assertChunkReassemblySupportedchecks 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 -- confirmpeer.connectstill defaults to the binarypack Binary serializer. A marker misparse either refuses the frame early (fail-closed) or runs the cursor off the end (delegated tounpack, 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 throughsendalready chunked),_bufferedSend(packed)(the point a datagram reaches the data channel, with PeerJS's own buffered-amount backpressure), andchunker.chunkedMTU(the 16,300-byte threshold).assertIterativePackingSupportedchecks 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_sendChunksand_bufferedSendunchanged, so PeerJS keeps chunking, buffering and backpressure; only the encode step is Alcove's. It drops the fifth path the original_sendhas,_send_blob(data)(taken whenpackreturns a promise for aBlob): Alcove sends noBlob, and one handed to the replacement refuses with ausage-kindConnectionErrorfrom the encoder rather than being packed asynchronously. That narrowing is intended and is driven byapps/web/test/unit/psi/iterativePacking.test.ts; a bump that made aBloba 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 toint64/uint64), thedoublelayout taken from a logarithm and a truncating multiply rather than from the IEEE-754 bits, UTF-8 strings under0xb0 + n/0xd8/0xd9, byte strings under0xa0 + n/0xda/0xdb, arrays under0x90 + n/0xdc/0xdd, and plain objects as maps under0x80 + n/0xde/0xdfwith keys packed as strings inObject.keysorder. This is the one assumption here that a suite enforces exactly rather than approximately:packages/core/test/connection/binaryPackEncodeDifferential.test.tscompares Alcove's bytes against the real packer's for the four record-scaling frames and every marker at its boundaries, andapps/cli/test/unit/connection/webrtcOutboundParity.test.tsdoes the same over the shared fixture set. A bump that moves any marker choice reddens there. - The chunk envelope shape
_handleChunkconsumes:__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'sUnpacker.unpack): fixint/fixraw/fixstr/fixarray/fixmap and the0xc0-0xdfmarkers, maps declaring two child values per pair, the raw-vs-str split at0xa0/0xb0, the fixed scalar skip widths and theu16/u32length-prefix widths, andunpack_string/unpack_rawadvancing the buffer cursor by exactly the declaredsizeregardless 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 apeerjs-js-binarypackbump. - 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 aBigInt, 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_rawproduces eachbin/rawvalue as a fresh per-value copy throughslice(...)of the unpacker's input -- aUint8Arrayview when the input is one (the chunked-completion path and the CLI), a bareArrayBufferotherwise -- 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 returns0(a positive fixint) rather than throwing, sounpack_arrayretains 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-prefixedread()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_mapbuilds a plain object and assigns each pair throughmap[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 aMap(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.packemits a map only for a plain JS object, whose own keys are strings by construction, and throws on aMaporSetrather 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/chunkedDataConnection): confirm_handleChunkstill reassembles into_chunkedDatakeyed by__peerDataand recurses into_handleDataMessageon completion; that_handleDataMessageis still the soleunpackpoint both the unchunked and reassembled paths flow through, and that what follows itsunpackis still the close/chunk/datadispatch the bound copies; thatpeer.connect's default serializer is still the binarypack Binary class (and that_chunkedData/_handleChunkremain specific to it); and that the chunk envelope still holds__peerData/n/total/data. On the send side, confirm_sendstill packs and then dispatches to_sendChunksor_bufferedSendon thechunker.chunkedMTUthreshold, and that_sendChunksstill routes each envelope back through the connection's ownsendwith the chunked flag set.node_modules/peerjs-js-binarypack/dist/binarypack.mjs(Unpacker.unpack): confirm the marker table above, thatunpack_string/unpack_rawadvance the cursor by exactly the declaredsize, thatunpack_arraysizes its store from the declared count before reading an element, thatunpack_rawstill copies throughslicerather than returning a shared window, and thatunpack_mapstill assigns throughmap[key] = value. On thePackerside, 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-confirmreadValueHeader/scanFrameStructurestill mirror that marker table.packages/core/src/connection/webrtcOutboundBound.ts: the sender-side frame check charges each chunk atPEERJS_CHUNK_MTUplus the widest chunk envelope. A unit test holds the constant to the installedutil.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-confirmassertChunkReassemblySupportedstill probes the three internals, that the wrap still applies the core constants at both of them, and thatdispatchDecodedFramestill matches PeerJS's dispatch.apps/web/src/psi/transport/iterativePacking.ts: re-confirmassertIterativePackingSupportedstill 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-confirmpeer.socket.start,peer.socket._socket,peer._lostMessagesandpeer._storeMessageall still exist; that_storeMessageis still the single entry point through which a message is held for a connection not yet set up; and thatsocket.startstill creates_socketsynchronously, since the guard reads it immediately after callingstart, 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
iceServerslist holding astunurl replaces werift's built-instun.l.google.com:19302default rather than adding to it. This is what makes a configuredstunlist the list actually used. A list holding nostunurl, whether empty, absent or holding onlyturnurls, means "use the default" (it does not mean "no STUN"), andgetConfiguration()echoes its input, so it confirms pass-through but not suppression. Suppression is held by a separate check that hooksdns.promises.lookup-- the entry point werift resolves a STUN hostname through -- and asserts a peer built with{}, or with a list holding only aturnurl, resolvesstun.l.google.comwhile a peer built with astunlist 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 onlyturnurls 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 aCANDIDATEit 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
connectionStatestill readsconnected, the data channel still readsopen, andbufferedAmountstays pinned at its peak. A partner whose peer connection is closed tells this side, which raises the data channel'scloseat once whileconnectionStatestill readsconnected; a partner that crashes or drops off the network sends nothing, and that is the case consent freshness alone detects. So the transport watchesconnectionStateto fail an exchange whose partner has gone; without it a party would wait out its hour-scale inactivity budget. sendaccepts aBuffer/Uint8Arrayandonmessagedelivers a NodeBuffer, andpackencodes aUint8Array, anArrayBufferand aBufferto identical bytes, so the framing is unambiguous in both directions.setRemoteDescriptionandaddIceCandidateaccept 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 aDOMException, 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'saddIceCandidatedoes not settle until the query is answered or 10 s pass (after ana=end-of-candidatesit 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 orgetStats, 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.localcandidates and then the one usablesrflxcandidate, 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
maxMessageSizeis 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
bufferedAmountfalls 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 onbufferedAmountand thebufferedamountlowevent (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
connectedtofailedabout 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. bytesSentandmessagesSentare 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 aMapwhose entries name their fields as the W3C statistics dictionaries do: atypeoflocal-candidate,remote-candidateorcandidate-pair, anid, acandidateType, and a pair'slocalCandidateId,remoteCandidateId,stateandnominated. 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
webrtcchannel 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'sa=fingerprintline 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'sconnectionStateatfailed, 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
iceServerslist and drops the rest. ItsparseIceServers, 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 whatparseIceServerskeeps. 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, theiceServerspass-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-claimsthen 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.tsfor 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.tsandnpm run test:unit -w apps/cli -- test/unit/connection/webrtcIceConfiguration.test.tsfor 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.tsfor the.localassumption. It holds that a channel whose only working path follows two unresolvable.localcandidates opens within 5 s of the description, in both roles; it does not time the lookup itself, so re-time oneaddIceCandidateof an unresolvable.localcandidate 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.tsfor 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.tsfor the refresh-timer assumption. It drivesTurnProtocolagainst 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/postinstallscript and ships no native or compiled content, so nothing compiles at install and noallowScriptsverdict 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:
- The digest in
Dockerfile.fips'sFROM, 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'samazonlinux:2023publishes the same releases under different digests, so the digest is resolved on the registry theFROMnames. Why the base comes from ECR Public is in fips-variant-image.md. - The
FIPS_BASEliteral inscripts/dockerfile-freeze.test.mjs.npm run test:scriptsstays red until it matches, so the reconciliation is forced rather than remembered. ARG AL2023_RELEASEVERinDockerfile.fips, the snapshot everydnftransaction resolves against. Thenodebasestage compares it against thesystem-releaseversion the rootfs reports and fails the build when the two differ, so a digest that moves without it reddens the image build.- The certified provider's pins --
FIPS_PROVIDER_PACKAGE,FIPS_PROVIDER_VERSIONandFIPS_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 thednf swapitself, and one serving a different module under the same package name fails therpm -qfandopenssl listassertions 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. - The
# base pin:header line inNOTICE-os-packages-fips.tsv, the OS-layer attribution list described in CONTAINER_IMAGES.md.scripts/generate-os-package-attribution.mjs --checkcompares that line against the digestDockerfile.fipsnames, soimage_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:scriptsreddens 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
aarch64and CI buildsamd64, 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
amd64with no QEMU, so on a pull request the release assertion speaks for theamd64rootfs at the new digest and not for thearm64one. The release workflow's multi-arch push builds this image for both architectures, so the assertion runs forarm64first 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:
- The digest in both of
Dockerfile'sFROM 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. - The
DEFAULT_BASEliteral inscripts/dockerfile-freeze.test.mjs.npm run test:scriptsstays 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. - The
# base pin:header line inNOTICE-os-packages-default.tsv, the OS-layer attribution list described in CONTAINER_IMAGES.md.scripts/generate-os-package-attribution.mjs --checkcompares that line against the digestDockerfilenames, soimage_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:scriptsreddens 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.