Package registries
Admin-managed npm registries that agent workspaces install through a worker-side proxy. The upstream credential never enters the container; the workspace holds only a per-workspace proxy token.
1. What it does
Section titled “1. What it does”An agent workspace runs npm/yarn/pnpm installs against the deployment’s package
registries. Without this feature the container hits the public npm registry (or whatever a
project .npmrc names) directly — which cannot reach an internal Artifactory and cannot use a
credential that must stay out of agent hands.
With REGISTRY_PROXY_PORT set, the worker listens on a registry proxy. Each workspace that
starts gets, before the container is created, a random 32-byte token registered at the proxy
(stored only as a sha256 digest). The container is configured — through environment and a
generated npmrc — to send installs to http://<proxy>/npm/<registryId>/… with that bearer
token. The proxy adds the real upstream credential server-side, so a token read out of the
container is good only at the proxy, only for that workspace’s lifetime, and in lockfile mode
only for the dependencies committed on the base ref. It is read-only: GET and HEAD only, plus the one POST described under Audit in §5.
The threat model covers both accidental leakage and a prompt-injected agent. The real registry credential never enters the container, so neither a log line nor an agent can expose it. The proxy enforces read-only, lockfile-scoped access server-side rather than trusting the client.
2. Registries
Section titled “2. Registries”PackageRegistry rows are managed by platform admins in Admin → Integrations → Package
registries and under POST/PATCH/DELETE /api/v1/admin/package-registries (GET lists). Each
row has a name, upstreamUrl, authKind (bearer, or basic where the stored value is
user:password), accessMode, allowPrivateNetwork (the SSRF check is waived for private
upstreams — Artifactory is usually internal), an optional teamId (null = every team),
npmScopes (e.g. @acme; empty = the default registry for unscoped packages), and a write-only
encrypted token (hasToken/tokenLastFour is all the API reports). Every change writes a config
audit-log entry.
Write-time uniqueness: among active rows of one team group (null is its own group), at most one
default registry and each scope at most once — clashes are refused with 409. Resolution for a
workspace of team T takes active rows where teamId IN (T, null); per key (the default key and
each scope) a team row beats a global one.
3. Access modes
Section titled “3. Access modes”open: the proxy forwards any package the upstream serves.lockfile(default): onlyname@versionpairs present in the workspace’s committed lockfiles resolve. The allowlist is built from the ref the workspace was cut from — HEAD after clone, the pinned SHA, or the fetchedorigin/<defaultBranch>for an existing-branch workspace — bygit ls-tree+git showoverpackage-lock.json,yarn.lockandpnpm-lock.yaml, never from the working tree, so an agent cannot widen its own allowlist. A lockfile that cannot be parsed contributes nothing (fail closed); no lockfiles means the allowlist is empty and every package is denied.
4. How clients are configured
Section titled “4. How clients are configured”Container env:
NPM_CONFIG_USERCONFIG=/workspace/.auto-swe/npmrcNPM_CONFIG_REGISTRY=<default proxy URL>(when a default registry exists)NPM_CONFIG_REPLACE_REGISTRY_HOST=alwaysNPM_CONFIG_AUDIT/FUND/UPDATE_NOTIFIER=false- for Yarn Berry:
YARN_NPM_REGISTRY_SERVER,YARN_NPM_AUTH_TOKENandYARN_NPM_ALWAYS_AUTH=true - for Corepack (when a default registry exists):
COREPACK_NPM_REGISTRY,COREPACK_NPM_TOKENandCOREPACK_ENABLE_DOWNLOAD_PROMPT=0
These env keys beat a project .npmrc or .yarnrc.yml, which is why the default registry goes
through env.
The two token variables (YARN_NPM_AUTH_TOKEN, COREPACK_NPM_TOKEN) are the workspace’s
secretEnv, set when the container is created. On Docker they are passed as -e NAME with the
value in the docker client’s environment. On Kubernetes they come from a per-pod Secret through
secretKeyRef, so the Pod object never holds the token (see
architecture.md). The npmrc, which carries the token
too, is written into the pod through stdin.
The generated npmrc is written chmod 600 both to NPM_CONFIG_USERCONFIG and to the exec
user’s $HOME/.npmrc. It holds:
- a host-level
//host:port/:_authTokenline andalways-auth=true - one
//host:port/npm/<id>/:_authTokenline per registry - one
@scope:registry=<proxy url>line per scope
Yarn classic needs the $HOME copy and the host-level line. It reads .npmrc only from the
project directory and $HOME, matches auth by host, and sends no auth header without
always-auth.
The exec user’s $HOME/.yarnrc.yml lists the proxy host on unsafeHttpWhitelist (Yarn 4
refuses an http:// registry otherwise) and the Berry npmScopes.
Package managers. defaults/Dockerfile.node pre-installs pnpm 10.34.7, Yarn classic 1.22.22
(the yarn default) and Yarn Berry 4.18.1 through Corepack, so a repository that pins none of
them, or pins one of these versions, starts its install with no download. Any other pin is fetched
by Corepack from the default registry through the proxy. The allowlist admits it, because
packageManager pins found in package.json files at the base ref (at the root and beside each
lockfile) are added to it: pnpm@v and npm@v as themselves, yarn@1.x as yarn, and
yarn@2+ as @yarnpkg/cli-dist.
Corepack reads the version manifest /<package>/<version> and downloads the tarball it names.
Unless the pin carries a hash (pnpm@9.15.9+sha512.<hex>), it then verifies the manifest’s
dist.signatures against npm’s public signing keys. The proxy passes signatures through
untouched, so a pin resolved against registry.npmjs.org, or a mirror that preserves them,
verifies. A hash-pinned packageManager is checked against its hash instead and needs no
signatures.
All four clients are tested end to end. npm ci, pnpm install --frozen-lockfile, Yarn
classic --frozen-lockfile and Yarn Berry --immutable each install through the proxy in
lockfile mode, and each shows the denial message when a package outside the lockfile is added.
The suite is registryProxy/clients.docker.test.ts, run in CI against the curated Node image.
It also has Corepack download a pin that the image doesn’t have, through the proxy. The cases
cover a valid signature, a forged one (refused), a hash pin with no signatures, and a pin outside
the allowlist (denied).
Shell steps. A network: 'egress' shell step gets the same wiring, with these differences:
- Its container’s root filesystem is read-only, so its
HOMEis/workspace/.auto-swe/homein the step volume, outside the checkout the finalize commits. - The npmrc and Berry rc are written there by a helper container before the command runs.
- The command container gets the host-gateway alias in its
--add-hostentries, beside any egress-allowlist entries. - The allowlist is built from
origin/<defaultBranch>, which the clone helper fetches before it scrubs the credential. - The token is revoked as soon as the command exits.
5. The proxy
Section titled “5. The proxy”URL layout. <baseUrl>/npm/<registryId>/<npm path>. Only three npm path shapes exist:
- packument:
/<name> - version manifest:
/<name>/<version>, which Corepack reads. In lockfile mode the version must be allowlisted, so a dist-tag such aslatestis denied. - tarball:
…/<name>/-/<file>.tgz
A scoped name may be written @scope/pkg, @scope%2fpkg or %40scope%2fpkg.
Anything else, including search and other /-/… endpoints, is a JSON 404. The one exception is the
audit query below.
Audit. Two query shapes are relayed to the default registry, with its credential, so a
runVulnScan gate works in a wired workspace. They are the only requests other than GET and HEAD.
| Client | Endpoint | Body |
|---|---|---|
npm audit, yarn npm audit |
POST …/-/npm/v1/security/advisories/bulk |
a JSON object of package names to non-empty lists of versions |
pnpm audit |
POST …/-/npm/v1/security/audits/quick, then …/audits if that does not answer 200 |
pnpm’s dependency tree: importers holding packages with version, integrity, dev, requires and nested dependencies |
Both bodies (gzip accepted, 8 MiB cap) are held to that shape and nothing else: every key outside
the known set is refused, every name, version and integrity value is held to its charset, and the
tree’s install, remove and metadata must be empty, so a body can carry nothing but the names
and versions of a lockfile. What is forwarded is the validated body re-serialised, not the bytes
received. In lockfile mode every name and version in it must also be in the committed lockfiles
(otherwise 403, reported like any other denial). A body that names a package another registry
serves (a claimed private scope) is refused with 403, not trimmed: sending it to the default
registry would disclose the name, and a trimmed audit would read as a complete one. The answer
(8 MiB cap) is relayed as JSON with the upstream’s status; whether it holds advisories depends on
the upstream — a mirror that does not serve the endpoint answers its own error (pnpm reports a 404
from both of its endpoints as an audit endpoint that does not exist), and the gate fails.
An audit is a buffered fetch like a packument, so it follows the same rules: it takes one of the
workspace’s places only after the body has passed the shape and lockfile checks (a refused body never
waits), waits for a place and gets 503 with Retry-After exactly as a packument does, reads the
client’s body and the upstream’s answer within the 20-second deadline (408 for a client that is too
slow, 502 for the upstream), and gives its place back at once if the client hangs up.
A tarball path may carry a prefix. Up to eight segments are allowed, and none may be . or
.. in any encoding. The proxy validates the prefix and then ignores it, and it strips an
@scope/ prefix from the file name. This is because npm keeps a lockfile resolved URL’s path
when replace-registry-host swaps its origin. So a lockfile resolved against Artifactory
(…/artifactory/api/npm/repo/@acme/pkg/-/@acme/pkg-1.0.0.tgz) arrives as
/npm/<defaultId>/artifactory/api/npm/repo/@acme/pkg/-/@acme/pkg-1.0.0.tgz.
Routing is by package name, not by the id in the URL. The id must belong to the workspace’s
registration, but a scoped package goes to the registry that claims its scope, and anything else
to the default registry. No match is a 404. The upstream request is always the canonical path on
the routed registry: <upstream>/<name>/-/<file>, or <upstream>/@scope%2fpkg for a scoped
packument.
Status codes:
- unknown token: 401
- unregistered
registryId: 404 - a method other than GET or HEAD, except the audit POST above: 405
The upstream request carries the routed registry’s credential header, the client’s accept
header, accept-encoding: identity for packuments, and redirect: 'error'. It goes through the
SSRF-guarded fetch, which honours each registry’s allowPrivateNetwork.
Not-ready state. Until the workspace’s allowlist is set, the registration answers 503.
createWorkspace returns only once it is set, so the agent never reaches that state.
Denials return a 403:
- body:
{"error":"auto-swe: <name>[@<version>] is not in the repository's committed lockfiles; …"} - the same message as the HTTP reason phrase, because pnpm prints only the reason phrase
- logged through the activity logger (
logWarn) with the container, registry and package
Packuments and version manifests are buffered (64 MiB cap) and never cached. They are
rewritten so every dist.tarball points back at the proxy, under the routed registry’s id;
nothing else in them changes. In a lockfile-mode packument, versions that aren’t allowed are
filtered out, along with their dist-tags and time entries. Because each one is held in memory
and re-serialised, a workspace has at most 8 of them in flight; a request past that waits (up to 64
waiting, above Yarn’s default of 50 concurrent requests, for at most 30 seconds) and then gets 503
with Retry-After, which npm and Yarn retry. A request takes its place only after the allowlist
check, so a denied package never waits. The upstream fetch and body read of a held place end at 20
seconds with a 502, and a client that hangs up gives its place back at once.
Tarballs stream with backpressure.
Cache. With REGISTRY_PROXY_CACHE_DIR set, tarballs are cached under
<dir>/<registryId>/<sha256(url)> — written to a temp file and renamed only on a complete 200,
so a partial response is never cached. A hit refreshes mtime; when the total exceeds
REGISTRY_PROXY_CACHE_MAX_BYTES (default 2 GiB) the oldest entries are evicted until usage is
under 90%. Cache errors fall through to the upstream.
6. Configuration
Section titled “6. Configuration”| Variable | Purpose |
|---|---|
REGISTRY_PROXY_PORT |
Enables the proxy; port the worker listens on. Unset = no registry wiring. |
REGISTRY_PROXY_URL |
Base URL a workspace reaches the proxy at; {hostname} resolves per replica. Default http://host.docker.internal:$PORT (the host-gateway alias is then added to the container). |
REGISTRY_PROXY_BIND |
Listen address, default 0.0.0.0. |
REGISTRY_PROXY_CACHE_DIR |
Tarball cache directory; unset disables the cache. |
REGISTRY_PROXY_CACHE_MAX_BYTES |
Cache cap, default 2 GiB. |
Limitations
Section titled “Limitations”- npm only, and read-only. Other ecosystems (PyPI, Maven, Go modules) are not served, and neither is publishing.
- Upstream redirects are refused (
redirect: 'error'). An Artifactory that redirects downloads to object storage does not work. - Yarn classic must have a default-host lockfile. Yarn classic rewrites only
registry.yarnpkg.comandregistry.npmjs.orghosts inyarn.lockto the configured registry. Ayarn.lockwhoseresolvedURLs name another host, such as an Artifactory, is fetched from that host directly, without the proxy, and fails. - Berry scoped registries live in the exec user’s home rc, and a project
.yarnrc.ymlscope entry overrides them. That fails closed: the scope resolves against the real registry without the proxy credential. - A Corepack pin needs signatures or a hash. An upstream that strips
dist.signaturesfrom version manifests fails Corepack’s verification for a pin without a+sha512.hash. The fix is to hash-pinpackageManager; the workspace never setsCOREPACK_INTEGRITY_KEYSto disable the check. - A dependency the agent adds cannot be installed in lockfile mode, gates included, until it is committed on the base branch and a fresh workspace is cut.
- Shell steps need egress and the default proxy address. A
network: 'none'step gets no registry access. So does a step on a deployment that advertises the proxy at a host other than the Docker host alias (aWORKSPACE_NETWORKhostname), because shell-step containers don’t join that network. The step runs without the wiring and logs a warning. Berry scoped registries in a shell step can likewise be overridden by the project’s.yarnrc.yml. - Container steps get no registry access. A container-contract step has no repository and no lockfile.
- The cache holds tarballs only; packuments are always re-fetched.
- Denials have no dedicated UI. They surface as 403s to the installer and as
logWarnentries on the run’s activity.
© 2026 Jorge Barnaby
Source-available under FSL-1.1-MIT