Skip to content

Commit 1dfdff8

Browse files
authored
fix: allow yarn's registry through the egress sidecar (SYD-269) (#231)
yarn defaults to registry.yarnpkg.com, which EGRESS_BASELINE never allowed, so every `yarn install` in a dispatched session got a 403 CONNECT from the syd-egress sidecar. Reproduced against the live sidecar: `npm view left-pad` returns 1.3.0 while `yarn add left-pad` fails with "tunneling socket could not be established, statusCode=403". HEX-4's session reported this as "no network access", which read as a generic sandbox limit rather than a one-host allowlist gap. Added to the baseline rather than derived from a project's stack.cli. Deriving is what the issue proposed and it looks tidier, but there is one shared sidecar per host and each worker stands it up from its own config: the base config serves the yarn project while the codex and gemini configs ask for github.com, so derivation makes those configs disjoint instead of nested and they rebuild the sidecar in turn -- the boot-order-dependent allowlist SYD-270 just fixed. The baseline keeps `satisfies()`'s nesting invariant intact and covers the next yarn project without anyone remembering to declare it. Exposure is unchanged: registry.yarnpkg.com is a CDN alias serving the same public packages as the already-allowed registry.npmjs.org. The two ensureEgressGuard suites now derive their expected ALLOWED_DOMAINS from egressAllowlist() instead of spelling it out -- those cases are about docker orchestration and credential injection, not allowlist contents, which are asserted directly. New tests pin the yarn host as unconditional and assert the nesting invariant a future derivation would break.
1 parent c913139 commit 1dfdff8

5 files changed

Lines changed: 73 additions & 13 deletions

File tree

Dockerfile.worker

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
2929
#
3030
# A concrete pinned binary rather than `corepack enable` on purpose: corepack
3131
# shims fetch whatever version a repo pins in "packageManager" lazily, at
32-
# session start, from repo.yarnpkg.com as well as registry.npmjs.org -- only
33-
# the latter is in the sidecar's allowlist (EGRESS_BASELINE in
34-
# scripts/worker-select.ts), so a shim would reintroduce the same silent
32+
# session start, from repo.yarnpkg.com -- which is NOT in the sidecar's
33+
# allowlist (EGRESS_BASELINE in scripts/worker-select.ts allows the npm and
34+
# yarn *registries*, registry.npmjs.org and registry.yarnpkg.com, but not
35+
# corepack's distribution host), so a shim would reintroduce the same silent
3536
# no-dependencies failure behind a network block. Pinned for reproducible
3637
# rebuilds, same as CLAUDE_CODE_VERSION above.
3738
ARG PNPM_VERSION=11.17.0

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,8 @@ this:
319319
`--internal` Docker network (`syd-workers`) with no route out; their only
320320
exit is a tinyproxy sidecar (`syd-egress`, built by
321321
`npm run build:worker-image` from `Dockerfile.egress-proxy`) that forwards
322-
only to `api.anthropic.com`, `registry.npmjs.org`, and the tracker host
322+
only to `api.anthropic.com`, `registry.npmjs.org`, `registry.yarnpkg.com`
323+
(yarn's default registry — SYD-269), and the tracker host
323324
from `url`. Add hosts with `egressAllow: ["host.name"]`; opt out entirely
324325
with `egress: "open"` in `switchyard-worker.json`. The worker stands the
325326
network and sidecar up automatically at startup (and refuses to start if

scripts/worker-select.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@ export type WorkerConfig = {
183183
* "open" is the escape hatch: plain default-bridge networking, full egress.
184184
*/
185185
egress?: "proxy" | "open";
186-
/** Extra hostnames the egress proxy should allow, beyond the tracker host,
187-
* api.anthropic.com, and registry.npmjs.org. */
186+
/** Extra hostnames the egress proxy should allow, beyond the tracker host
187+
* and EGRESS_BASELINE (api.anthropic.com plus the npm and yarn registries). */
188188
egressAllow?: string[];
189189
};
190190

@@ -947,9 +947,25 @@ export function injectKeyNames(env: NodeJS.ProcessEnv): string[] {
947947
return PROVIDER_KEY_VARS.filter((v) => env[v]).sort();
948948
}
949949

950-
/** Baseline hosts every session needs: the Anthropic API and npm's registry
951-
* (sessions run `npm ci`). The tracker host comes from config.url. */
952-
const EGRESS_BASELINE = ["api.anthropic.com", "registry.npmjs.org"];
950+
/**
951+
* Baseline hosts every session needs: the Anthropic API and the package
952+
* registries sessions install from. The tracker host comes from config.url.
953+
*
954+
* Both registries are unconditional rather than derived from a project's
955+
* `stack.cli` (SYD-269). Deriving looks tidier — only the workers serving a
956+
* yarn project would allow yarn's registry — but there is ONE shared
957+
* `syd-egress` sidecar for the whole host and each worker stands it up from
958+
* its own config. Derivation would make those configs disjoint rather than
959+
* nested (the base config serves a yarn project, the codex/gemini ones ask for
960+
* `github.com`), and disjoint configs rebuild the sidecar in turn — exactly
961+
* the boot-order-dependent allowlist SYD-270 fixed. Keeping the registries in
962+
* the baseline preserves the nesting invariant `satisfies()` relies on, and
963+
* covers the next yarn project without anyone remembering to.
964+
*
965+
* The added exposure is nil: registry.yarnpkg.com is a CDN alias serving the
966+
* same public npm packages as registry.npmjs.org, which is already allowed.
967+
*/
968+
const EGRESS_BASELINE = ["api.anthropic.com", "registry.npmjs.org", "registry.yarnpkg.com"];
953969

954970
export function egressMode(config: WorkerConfig): "proxy" | "open" {
955971
return config.egress ?? "proxy";

tests/scripts/worker-select.egress.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from "vitest";
22
import {
33
ensureEgressGuard,
4+
egressAllowlist,
45
injectKeyEnvArgs,
56
EGRESS_CA_VOLUME,
67
EGRESS_CA_DIR,
@@ -20,7 +21,10 @@ const config: WorkerConfig = {
2021
projects: { SYD: { repo: "/repo/syd" } },
2122
};
2223

23-
const domainsCsv = "api.anthropic.com,localhost,registry.npmjs.org";
24+
// Derived rather than spelled out: these cases are about credential injection
25+
// and the CA volume, not the allowlist's contents (asserted in
26+
// worker-select.test.ts), so a new baseline host shouldn't break them.
27+
const domainsCsv = egressAllowlist(config).join(",");
2428

2529
type Call = { cmd: string; args: string[] };
2630
function mockExec(respond: (call: Call) => string | Error) {

tests/scripts/worker-select.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
buildDockerArgs,
1414
egressMode,
1515
egressAllowlist,
16+
satisfies,
1617
ensureEgressGuard,
1718
buildContainerizedPrompt,
1819
stackChecksEnv,
@@ -665,11 +666,12 @@ describe("egress config (SYD-110)", () => {
665666
expect(egressMode({ ...config, egress: "proxy" })).toBe("proxy");
666667
});
667668

668-
it("egressAllowlist covers the Anthropic API, npm registry, and the tracker host", () => {
669+
it("egressAllowlist covers the Anthropic API, both package registries, and the tracker host", () => {
669670
expect(egressAllowlist(config)).toEqual([
670671
"api.anthropic.com",
671672
"localhost",
672673
"registry.npmjs.org",
674+
"registry.yarnpkg.com",
673675
]);
674676
});
675677

@@ -680,7 +682,40 @@ describe("egress config (SYD-110)", () => {
680682
url: "http://nas.local:3300",
681683
egressAllow: ["github.com", "api.anthropic.com"],
682684
}),
683-
).toEqual(["api.anthropic.com", "github.com", "nas.local", "registry.npmjs.org"]);
685+
).toEqual([
686+
"api.anthropic.com",
687+
"github.com",
688+
"nas.local",
689+
"registry.npmjs.org",
690+
"registry.yarnpkg.com",
691+
]);
692+
});
693+
694+
// SYD-269: yarn defaults to registry.yarnpkg.com, so a session for a yarn
695+
// project got a 403 CONNECT from the sidecar on every install. Assert the
696+
// host is unconditional — a project declaring `yarn` in stack.cli must not be
697+
// what turns it on, or the base config and the codex/gemini ones (which ask
698+
// for github.com and serve no yarn project) become disjoint instead of
699+
// nested, and rebuild the shared sidecar in turn. See the next test.
700+
it("egressAllowlist allows the yarn registry regardless of any project's stack", () => {
701+
expect(egressAllowlist(config)).toContain("registry.yarnpkg.com");
702+
expect(
703+
egressAllowlist({
704+
...config,
705+
projects: {
706+
SYD: { repo: "/repo/syd", stack: { cli: [{ name: "yarn", check: "yarn -v" }] } },
707+
},
708+
}),
709+
).toEqual(egressAllowlist(config));
710+
});
711+
712+
// The invariant satisfies() depends on (SYD-270): every worker config on the
713+
// host must be nested, so the widest one creates the sidecar and the rest
714+
// accept it. Disjoint sets ping-pong the sidecar on every boot.
715+
it("a config's extras keep it a superset of the bare config — sidecar sets stay nested", () => {
716+
const base = egressAllowlist(config);
717+
const withExtras = egressAllowlist({ ...config, egressAllow: ["github.com"] });
718+
expect(satisfies(withExtras, base)).toBe(true);
684719
});
685720
});
686721

@@ -700,7 +735,10 @@ describe("ensureEgressGuard (SYD-110)", () => {
700735
return { calls, exec };
701736
}
702737

703-
const domainsCsv = "api.anthropic.com,localhost,registry.npmjs.org";
738+
// Derived, not spelled out: these cases exercise the guard's docker
739+
// orchestration, not the allowlist's contents (asserted directly above), so
740+
// adding a baseline host shouldn't have to be re-typed here.
741+
const domainsCsv = egressAllowlist(config).join(",");
704742
// SYD-186: the sidecar now also injects provider creds; env supplies them and
705743
// seeds the INJECT_KEYS freshness sentinel (CLAUDE_CODE_OAUTH_TOKEN here).
706744
const egressEnv = { CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat-REAL" } as NodeJS.ProcessEnv;

0 commit comments

Comments
 (0)